diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0b528ae10..c63fdeef5 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -26,15 +26,15 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 300 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Optional font build input, so the Japanese font tests run (they skip without it). - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: 8dcdacd5153e64560d109541a47d806f26f048c0 path: craft-fonts - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 0e2388a62..46ce3c908 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -23,7 +23,7 @@ jobs: env: ACTIONLINT_VERSION: 1.7.12 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install tools run: | sudo apt-get update @@ -51,6 +51,11 @@ jobs: if grep -oE '' 'Value="InstallDirDlg"' '' \ + 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do + grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; } + done desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml" appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9c3a1ea3..c81bf0507 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: date: ${{ steps.v.outputs.date }} prerelease: ${{ steps.v.outputs.prerelease }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: v name: Resolve version run: | @@ -72,17 +72,17 @@ jobs: LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-macos-universal - name: Apply version override @@ -102,7 +102,7 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-macos path: dist/release/* @@ -130,23 +130,24 @@ jobs: LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-windows-${{ matrix.arch }} - name: Apply version override if: inputs.version != '' shell: bash run: cargo xtask version set "$LIGHTCRAFT_VERSION" + # package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache. - name: Install WiX shell: pwsh run: dotnet tool install --global wix --version 5.0.2 @@ -164,7 +165,7 @@ jobs: AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-windows-${{ matrix.arch }} path: dist/release/* @@ -187,9 +188,9 @@ jobs: LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} NFPM_VERSION: 2.47.0 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately @@ -203,8 +204,8 @@ jobs: curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-linux-${{ matrix.arch }} - name: Apply version override @@ -228,7 +229,7 @@ jobs: "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' test -s "$appimage.zsync" done - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/release/* @@ -250,8 +251,8 @@ jobs: LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: release-linux-${{ matrix.arch }} path: dist/linux @@ -265,7 +266,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Build the Flatpak bundle run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-flatpak-${{ matrix.arch }} path: dist/release/*.flatpak @@ -282,16 +283,16 @@ jobs: LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # 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). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc + uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9 with: release: "14.3" usesh: true @@ -316,7 +317,7 @@ jobs: bash packaging/freebsd/package.sh tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \ | grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$' - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-freebsd-x86_64 path: dist/release/* @@ -331,17 +332,17 @@ jobs: LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} WASM_BINDGEN_VERSION: 0.2.129 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: storytold/craft-fonts ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately path: craft-fonts - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: release-web - name: Install wasm-bindgen CLI @@ -359,7 +360,7 @@ jobs: CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts CRAFT_FONTS_REQUIRED: "1" run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-web path: dist/release/* @@ -378,7 +379,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: release-* path: artifacts diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d6067b3f2..1cccd858b 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -36,11 +36,11 @@ jobs: runs-on: windows-latest timeout-minutes: 90 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable with: targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: key: windows-arm64-package - name: Install WiX @@ -51,7 +51,7 @@ jobs: env: DIST: ${{ github.workspace }}\dist\arm64 run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: lightcraft-windows-arm64 path: dist/arm64/* @@ -64,7 +64,7 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: lightcraft-windows-arm64 path: dist diff --git a/CLAUDE.md b/CLAUDE.md index 994920d3d..577458965 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,9 @@ of it. Full standard: `../craftrules/standards/never-crash.md` the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`. - Export goes through `lightcraft_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in `crates/ui-egui/src/menus.rs`. +- Text boxes use `crates/ui-egui/src/text_field.rs` (`TextField`), not `egui::TextEdit`: it gives every field the + Cut / Copy / Paste / Select All menu, Esc restoring the text, and reports how an edit ended. Give each a widget id + that is unique on screen. Older fields still on `TextEdit` move over as they are touched. - Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`. - Parallel agents: separate git worktrees and `CARGO_TARGET_DIR=target/agent-`; each agent uses its **own control port** (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (`//`) — never `rm -rf` shared paths; delete your target dir when done (disk is shared); keep every `Cargo.toml` valid at all times (the `crates/*` glob means one broken manifest breaks everyone). - Test corpora: `cargo xtask corpus --download` into `corpus/` (gitignored, CC0 only). Never commit media. diff --git a/Cargo.lock b/Cargo.lock index 45d8ff15c..e8037a6f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2976,7 +2976,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.4.0" +version = "0.5.0" dependencies = [ "eframe", "egui", @@ -2994,10 +2994,11 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.4.0" +version = "0.5.0" dependencies = [ "crc32fast", "lightcraft-develop", + "lightcraft-geom", "lightcraft-meta", "log", "proptest", @@ -3009,7 +3010,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "dhat", "lightcraft-codecs", @@ -3024,8 +3025,9 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.4.0" +version = "0.5.0" dependencies = [ + "avif-serialize", "bytemuck", "image", "image-webp", @@ -3039,6 +3041,7 @@ dependencies = [ "moxcms 0.9.1", "png", "proptest", + "rav1e", "ravif", "rayon", "serde", @@ -3050,7 +3053,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.4.0" +version = "0.5.0" dependencies = [ "lightcraft-geom", "proptest", @@ -3059,7 +3062,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.4.0" +version = "0.5.0" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3076,7 +3079,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.4.0" +version = "0.5.0" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3091,7 +3094,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.4.0" +version = "0.5.0" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3103,7 +3106,7 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.4.0" +version = "0.5.0" dependencies = [ "ab_glyph", "harfrust", @@ -3118,6 +3121,7 @@ dependencies = [ "lightcraft-fetch", "lightcraft-geom", "lightcraft-gpu", + "lightcraft-heif", "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", @@ -3139,7 +3143,7 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.4.0" +version = "0.5.0" dependencies = [ "matrixmultiply", "ndarray", @@ -3153,7 +3157,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.4.0" +version = "0.5.0" dependencies = [ "log", "rustls", @@ -3164,7 +3168,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3172,7 +3176,7 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.4.0" +version = "0.5.0" dependencies = [ "bytemuck", "lightcraft-color", @@ -3181,6 +3185,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-pipeline", "lightcraft-raster", + "lightcraft-raw", "lightcraft-scenes", "log", "pollster 1.0.1", @@ -3189,14 +3194,15 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.4.0" +version = "0.5.0" dependencies = [ "heic-rs", + "rayon", ] [[package]] name = "lightcraft-mcp" -version = "0.4.0" +version = "0.5.0" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3207,7 +3213,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.4.0" +version = "0.5.0" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3223,7 +3229,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.4.0" +version = "0.5.0" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3235,7 +3241,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.4.0" +version = "0.5.0" dependencies = [ "image", "lightcraft-color", @@ -3252,7 +3258,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.4.0" +version = "0.5.0" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3264,7 +3270,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.4.0" +version = "0.5.0" dependencies = [ "libc", "lightcraft-color", @@ -3276,7 +3282,7 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.4.0" +version = "0.5.0" dependencies = [ "jpeg-encoder", "jxl-oxide", @@ -3298,7 +3304,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.4.0" +version = "0.5.0" dependencies = [ "image", "lightcraft-color", @@ -3309,7 +3315,7 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.4.0" +version = "0.5.0" dependencies = [ "candle-core", "candle-nn", @@ -3322,11 +3328,11 @@ dependencies = [ [[package]] name = "lightcraft-sysmem" -version = "0.4.0" +version = "0.5.0" [[package]] name = "lightcraft-tiff" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3335,7 +3341,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.4.0" +version = "0.5.0" dependencies = [ "egui", "lightcraft-catalog", @@ -3359,7 +3365,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.4.0" +version = "0.5.0" dependencies = [ "crc32fast", "eframe", @@ -6819,7 +6825,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.4.0" +version = "0.5.0" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 5f7c85db6..58e5383b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -68,6 +68,9 @@ moxcms = { version = "0.9", default-features = false, features = ["lut"] } jxl-oxide = { version = "0.12", default-features = false } zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only) ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust) +# HDR AVIF (PQ): ravif fixes the sRGB transfer curve, so HDR files drive its rav1e + avif-serialize directly +rav1e = { version = "0.8.1", default-features = false, features = ["threading"] } +avif-serialize = "0.8.6" # gpu (crates/gpu; native backends only, no GL: everything stays pure Rust) # SAM 3 inference (lightcraft-segment). 0.9.2: later releases pull `tokenizers` with the diff --git a/README.md b/README.md index bca63f8a8..217092fbf 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,20 @@ lightcraft --control 7980 ~/Pictures/trip path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app) and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`. - **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step. +- **Contact sheets (native app / CLI):** select photos, then **File → Contact Sheet PDF…**. Choose A4 or Letter, landscape, + rows/columns and filename captions; save a paginated PDF and print it from your PDF viewer. Exports use the + current edits, fit each photo without further cropping, and render at 150 dpi in sRGB. The background export + supports cancellation and protects originals and XMP sidecars. From the CLI: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot library.selectAll export.contactSheet path=Contact.pdf paper=a4 columns=3 rows=4 captions=true + ``` + + `export.contactSheet` also accepts explicit `ids`, `landscape=true`, and `paper=letter`. It replaces an existing + output only after the entire PDF succeeds. Limits: 1,000 photos, 100 pages, 256 MiB; captions are rasterized + 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. - **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. @@ -269,7 +283,8 @@ lightcraft --control 7980 ~/Pictures/trip - **GPU-accelerated, CPU-exact.** The whole develop pipeline runs as wgpu compute kernels (Metal / Vulkan / DX12), checked against the CPU pipeline to within 1/255. On a 24 MP raw (Apple M4 Pro): a slider update re-renders in ~4 ms, a cold 2.5 MP loupe in ~30 ms, and a full-size export in ~0.3 s including a parallel JPEG encode. - Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. + Without a GPU the same pipeline runs on all CPU cores, redoing only the stages a slider affects. Batch exports + render several photos side by side, so one's decode and encode overlap another's render. - **Instant culling.** Opening a raw shows its embedded camera preview or cached render within ~0.1 s while the full render follows (~0.2–0.5 s for 24 MP). The next and previous photos are prepared in the background, so stepping through a shoot takes ~50 ms per photo. @@ -289,10 +304,10 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the - **As a day-to-day Lightroom replacement we're nearer 60–70%.** It's great for JPEG/DNG and most Nikon / Sony / older-Canon raws on one machine. - **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-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 ILCE-7CR, ILCE-7M4, X-H2S and X-T4 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; - - **HDR, video and the Classic Print / Book / Map modules.** + - **on-screen HDR display, video and the Classic Print / Book / Map modules.** - **What's next:** see [where we're going](ROADMAP.md#where-were-going). | Area | Status | @@ -308,7 +323,8 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed), Fujifilm RAF (uncompressed + lossless/lossy compressed, Bayer + X-Trans), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format, DMC-LX1 to DC-S1RM2), Pentax PEF, Olympus ORF (uncompressed), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🟡 · CR3, compressed ORF and compressed SRW decode ⬜ | | Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise, Super Resolution ⬜ | | Remove / Heal / Clone spots (auto source), Visualize Spots, Red Eye and Pet Eye (auto pupil detection, catchlight) | ✅ · content-aware fill, spot pin editing 🚧 | -| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ · HDR export ⬜ | +| Export: JPEG / PNG / TIFF / WebP / AVIF / DNG / original, sizing, file-size limit, output sharpening, naming templates, batch, metadata policy, text or image watermark | ✅ | +| HDR: HDR editing with a headroom limit, SDR rendition, Visualize HDR; export as ISO 21496-1 gain map JPEG, PQ AVIF or 32-bit float TIFF | ✅ · HDR display ⬜ | | Library persistence (crash-safe op log + snapshots, background compaction, failed saves reported), disk thumbnail cache | ✅ | | Import: Add in place / Copy / Move, rename and folder templates, devices, duplicate detection, watched folders; Local folder browsing | ✅ | | MCP server (headless or live app, persistent libraries), CLI, control channel | ✅ | @@ -316,7 +332,7 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the | Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files and Panasonic / Leica RW2 / RWL distortion data), Geometry (transforms, Constrain Crop), Upright (Auto/Level/Vertical/Full/Guided) | ✅ · camera lens profiles (our own) ⬜ | | Photo Merge: HDR (auto-align, deghost), Panorama (spherical/cylindrical/perspective, boundary warp, auto crop), HDR Panorama → DNG | ✅ | | GPU pipeline (wgpu compute, CPU-exact within 1/255), CPU fallback on device limits / errors | ✅ · WebGPU in the browser 🚧 | -| AI: segmentation masks, AI denoise, super resolution, faces; HDR editing; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) | +| AI: segmentation masks, AI denoise, super resolution, faces; video | ⬜ (see [roadmap](ROADMAP.md#where-were-going)) | | Web build (same UI in the browser via WASM): persistent library in OPFS/IndexedDB, Web Worker rendering, export downloads | ✅ · WebGPU, Safari/Firefox testing 🚧 | ✅ works today · 🚧 in progress · ⬜ not started @@ -354,7 +370,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; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) 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 @@ -444,7 +460,9 @@ Questions, ideas or a bug you'd like to talk through first? Bring them to [Disco | arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. +Installers and executables are code-signed. The installer asks where to install (`C:\Program Files\LightCraft` by +default; upgrades stay in the folder you chose) and ends on a page confirming LightCraft was installed, with an +option to start it. For unattended installs: `msiexec /i lightcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\LightCraft\"`. ### macOS diff --git a/ROADMAP.md b/ROADMAP.md index c4bd73cef..0986772bd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,13 +23,13 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Estimate | What's true today | Biggest gaps | |---|---:|---|---| | **Feature checklist** | 79.7% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P0: full-resolution zoom detail; P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim | -| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~55 corpus files vs >1,000 models) | -| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | +| **RAW coverage** (formats people shoot) | ~55% | DNG (all kinds), CR2, CR3 lossless Bayer and version 0x100/0x200 C-RAW (M50/R100/R8 pixel-exact corpus), ARW, NEF (uncompressed + Huffman lossless/lossy), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans), uncompressed ORF, uncompressed Samsung SRW (NX5 to NX1100, EX1, WB2000), RW2 / RWL / Panasonic RAW (every raw format, checked on 178 files from 118 Panasonic and Leica bodies), PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini), Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Nikon High Efficiency NEF (HE / HE★: Z 8, Z 9, Z 6III, Z f; JPEG XS-based, preview only, #193). Per-model verification is thin (~55 corpus files vs >1,000 models) | +| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW, NEF, RW2, RAF, CR3, CR2 and PEF have guarded camera-JPEG colour estimates, with bundled ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles (docs/camera-preview-colour.md); 52 models whose JPEG fit is rejected start from matrices fitted to measured spectral sensitivities; other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) | | **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics, or the photo's own segmentation mattes when it ships them (DNG semantic masks, e.g. iPhone ProRAW's sky matte); non-destructive Bayer RAW denoise with opt-in weights on pure-Rust CPU/GPU; **faces (MVP, [docs/faces.md](docs/faces.md))**: a pure-Rust detector (a 232 KB opt-in download), opt-in recognition models (one-click download or add a file; they run on the shared pure-Rust CPU interpreter; pinned SFace/AuraFace embeddings agree with an independent reference runtime on 40 public crops; one-thread medians 34–36 / 233–252 ms (AuraFace remains expensive)), a background scan that finds and embeds faces, name suggestions, a person's page with look-alike faces to confirm, an Unnamed faces section (look-alikes together, select a group and name it at once) | Real segmentation masks (subject, sky, people, objects, landscape, depth), denoise model policy / linear RGB / quality verification, super resolution, lens blur, generative remove, natural-language search; for faces: clusters with separators between groups, small faces in large photos (the detector looks at 640 px), thresholds calibrated on live faces, writing face regions back to XMP. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) | | **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering | -| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ | -| **HDR & video** | 0% | | HDR edit/display/export; video play/trim/edit/export | -| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese and German UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | +| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic), paginated contact-sheet PDF export (A4/Letter, layout/captions, current edits, 150 dpi sRGB) | **Map view, Book, full Print module, Slideshow module, Web, publish services**: contact sheets work, custom print packages and printer colour management remain missing | +| **HDR & video** | ~25% | HDR editing (headroom limit, SDR rendition, Visualize HDR); gain map JPEG, PQ AVIF and float TIFF export | On-screen HDR display, HLG, HDR settings from crs XMP; video play/trim/edit/export | +| **Platform & robustness** | ~70% | macOS native; Windows/Linux builds; web via WASM; no-panic lints workspace-wide, `unsafe` confined to `crates/sysmem`; failed saves are reported; GPU errors fall back to CPU | Windows installer UI unverified on Windows (PR #79); GPU path proven only on Apple + user reports; English, Simplified/Traditional Chinese, Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI (see docs/localization.md); remaining technical errors; Traditional Chinese uses the Simplified Chinese font until craft-fonts has a TC face; accessibility partial; headless UI tests time out under machine load | ### By kind of user @@ -48,9 +48,9 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par 1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use matrices the files carry themselves, then chart shots. Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, - Pentax PEF and Samsung SRW have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF since issue #310, not yet - checked on the corpus); 52 models start from matrices fitted to measured spectral sensitivities (rawtoaces-data); - then ORF, then validate fidelity. + Pentax PEF, Samsung SRW and Olympus ORF have the file-local fit (matrix + tone curve from their own JPEG; CR2 and PEF + since issue #310, not yet checked on the corpus); 52 models whose fit is rejected fall back to matrices fitted to + measured spectral sensitivities (rawtoaces-data); then validate fidelity. 2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): Remaining **unverified CR3 CRX** variants, compressed ORF, NEF lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source, no LGPL dependency); compressed NEF (#86) is the template. @@ -63,13 +63,18 @@ Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/par segmentation masks and denoise. Bayer denoise inference is implemented in pure Rust with an opt-in GPL model; release policy and independently licensed weights remain open. Unblocks M12 and Enhance. Face detection and recognition run on our own checked Rust engine. Object / Describe masks now run Meta's SAM 3 in pure Rust (optional, user-consented download under the non-OSI SAM License; CDN mirrors still to be set up, docs/ai-masks.md); Subject / Sky / People, Raw Details and Super Resolution are still open. -7. **Then:** HDR (Q), the Classic output modules (Print first, then Map view, Book, Slideshow), video (R), localisation +7. **Then:** HDR (Q), the Classic output modules (expand contact-sheet PDF export into full Print, then Map view, Book, Slideshow), video (R), localisation and accessibility. Already closed in the week of 2026-10-03: compressed NEF (#10), Move import (#29), per-library smart-preview folder, Local roots / cleanup, grid and catalog performance at 85k photos (#35, #37), failed-save reporting, GPU export hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help and folder templates (#31, #32). +Library folders now have records of their own in the catalog (`Catalog.folder_records`, keyed by folder path; colour +labels first). Photos still store full paths, so moving a whole disk or folder relinks each photo. If that becomes a +problem (an "Update Folder Location" for a renamed or replaced drive, journal growth on large moves), the next step is +the Classic model: photos point to a folder id (root + relative path), and the records keyed by path become those rows. + Image navigation now supports native pinch zoom and two-finger pan in Detail, Compare and Reference views. The whole-image preview still has a configurable size cap (2,560 px default), so 100% on larger photos does not yet guarantee original-pixel detail; visible-region rendering remains a gap (LR-VIEW-ZOOM). @@ -90,13 +95,13 @@ yet guarantee original-pixel detail; visible-region rendering remains a gap (LR- | M7 | Detail | sharpening + masking preview, luminance/colour NR, Denoise, Raw Details, Super Resolution | 6–10 | 🚧 (sharpening, luminance/colour NR ✅; AI Denoise 🟡 (Bayer, optional weights; policy/fidelity open); Raw Details, Super Resolution ⬜) | | M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) | | M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ | -| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) | +| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs, HDR gain map JPEG / PQ AVIF ✅; JXL encode ⬜) | | M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (CR3 lossless CRX and C-RAW, RAF uncompressed and lossless/lossy compressed, RW2 (every raw format), PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW, NEF, RW2 and RAF preview fitting; measured database still missing), CR3, compressed ORF ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ | | M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ | | M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ | | M15 | Classic modules | Map, Book, Slideshow, Print, Web; smart collections, stacks, virtual copies, publish services, tethering | 25–40 | 🚧 (smart albums, stacks, virtual copies, compare/survey ✅; Map/Book/Slideshow/Print/Web ⬜) | -| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/German localisation 🟡; accessibility and further locales ⬜) | +| M16 | 1.0 polish | preferences, shortcut editor, accessibility, localization, packaging (dmg/msi/AppImage/web), hardening | 10–20 | 🚧 (settings, keyboard shortcuts sheet, packaging basics ✅; English/Chinese (Simplified, Traditional)/Japanese/Brazilian Portuguese/Spanish/German/Russian/Ukrainian localisation 🟡; accessibility and further locales ⬜) | ## Parity estimate (feature count updated 2026-10-08; effort estimate from 2026-10-02) @@ -165,7 +170,8 @@ The milestone estimates in the table above were made before work started and are published alongside GPL code); decoder source is never read. Still open: freedom-to-operate review for local Laplacian filters, PatchMatch and HEVC (HEIC). - **Look parity** with Adobe's default rendering is tuned by eye today; the planned fidelity suite (LR-BEHAV-RENDER-FIDELITY) - turns it into measured comparisons against local-only Lightroom references. + turns it into measured comparisons against local-only Lightroom references. Retuning changes existing edits, so it + ships as a new process version (`docs/process-versions.md`): photos keep their look until they are updated. ## Raw format coverage and known gaps @@ -176,20 +182,16 @@ CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pix Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed, LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files, -lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered -maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and +lossy ones unclipped in the declared linear primaries, checked on one real file), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the encrypted +`SR2SubIFD`, decrypted with a keystream recovered by black-box analysis, `crates/raw/src/vendor/sr2.rs`, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed and lossless/lossy compressed (Bayer and X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8, packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from 118 bodies, `crates/raw/src/vendor/rw2.rs`), PEF (uncompressed and Huffman), ORF uncompressed (16-bit and 12-bit packed). Supported containers yield their embedded JPEG preview when present (including CR3). The engine uses it for RAW variants it cannot decode yet; HEVC-only CR3 previews are not decoded. Not decoded yet — preview only (no permissively licensed description; black-box analysis incomplete): -- **Nikon "lossy after split" NEF** (non-zero split row in maker note `0x0096`, e.g. some D3400/D5000/D5200/D5500/D5600 - files): rows above the split decode with the regular lossy table; from the split row on a different code is used - that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte - alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis). - **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**. -**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF and Samsung SRW files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models without a camera profile start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged +**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2, PEF, Samsung SRW and Olympus ORF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. 52 models whose fit is rejected (or that have no usable JPEG) start from matrices fitted to their measured spectral sensitivities instead (rawtoaces-data, Apache-2.0; `crates/raw/src/spectral.rs`). Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged `matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer matrices stored in the files themselves (Olympus ImageProcessing `ColorMatrix`, Pentax equivalents; Panasonic RW2 files carry none) and our own chart-based calibration (M11.4). Adobe matrices are never used. @@ -221,13 +223,16 @@ database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-cat 16-bit words), black levels, in-camera aspect crops and mapped-out defects established on 178 CC0 files from 118 bodies; RW2 gets the file-local camera look (accepted on 140 of 174 files with a preview). - 2026-10-08: independent Rust CR3 lossless and version 0x100/0x200 C-RAW decoding, six exact full-sensor regressions on M50/R100/R8; any CR3 decode error falls back to the embedded JPEG; guarded CR3 camera-JPEG colour fitting with independent framing validation (`docs/cr3.md`). Unverified CRX variants and broader model verification remain. +- 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; all 131 photos accepted, where 8 fell back to the neutral matrix without it. Measured calibration remains open. ## Chinese and Japanese interfaces, and text watermarks English, Simplified Chinese, Traditional Chinese (Taiwan) and Japanese interface languages are persisted in UI state, and the language table (`crates/ui-egui/src/i18n.rs`) drives the menus, settings and fonts, so another language is a table entry plus two catalogs (docs/localization.md). Core menus and panels are -translated; untranslated text retains English. CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI: +translated; untranslated text retains English. Ukrainian covers all existing catalog keys, command/control/rule labels +and format messages, with count-neutral labels and numeric dates; technical errors from lower layers remain English. +CJK glyphs (Chinese: Noto Sans CJK SC; Japanese UI: BIZ UDPGothic; watermarks: BIZ UDMincho) come from storytold/craft-fonts, embedded by builds made with the optional `CRAFT_FONTS_DIR` input (all releases), so no system fonts are needed. Text watermarks now accept `vertical: true` in export JSON/presets and expose an orientation selector. Japanese diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 7c9d255e9..592a534a9 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -391,7 +391,7 @@ fn merge(args: &[String]) -> Result<(), String> { } i += 1; } - let mut s = Session::new().with_fs().with_default_denoise_models(); + let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock(); let paths = expand_paths(&files); let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; let mut ids: Vec = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); @@ -652,7 +652,7 @@ fn render(args: &[String]) -> Result<(), String> { } let input = input.ok_or("render: missing input file")?; let output = output.ok_or("render: missing -o OUTPUT")?; - let mut s = Session::new().with_fs().with_default_denoise_models(); + let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock(); let abs = expand_paths(std::slice::from_ref(&input)); let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?; let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?; @@ -732,12 +732,12 @@ 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_default_face_models(); + let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); 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_default_face_models(), - None => Session::new().with_fs().with_default_denoise_models().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(), + None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(), }; // 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())?; diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index 2009e818d..ecda1102a 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -7,6 +7,46 @@ use serde_json::{Value, json}; const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli"); +#[test] +fn contact_sheet_exports_pdf_and_protects_originals() { + let input = tmp("contact-sheet-input.png"); + gradient_png(&input); + let out = tmp("contact-sheet.pdf"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "export.contactSheet", + &format!("path={}", out.display()), + "paper=letter", + "landscape=true", + "columns=2", + "rows=3", + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + assert_eq!(lines[0]["result"]["pages"], 1); + assert_eq!(lines[0]["result"]["photos"], 1); + let bytes = std::fs::read(&out).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.4")); + assert!(String::from_utf8_lossy(&bytes).contains("/MediaBox [0 0 792.00 612.00]")); + let before = std::fs::read(&input).unwrap(); + for target in [input.clone(), input.with_extension("xmp")] { + if target != input { + std::fs::write(&target, b"existing sidecar").unwrap(); + } + let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", target.display())], None); + assert!(!ok, "overwrote protected file"); + } + assert_eq!(std::fs::read(&input).unwrap(), before); + assert_eq!(std::fs::read(input.with_extension("xmp")).unwrap(), b"existing sidecar"); + let prior = std::fs::read(&out).unwrap(); + let (ok, _, _) = run_cli(&["--import", input.to_str().unwrap(), "export.contactSheet", &format!("path={}", out.display()), "columns=0"], None); + assert!(!ok); + assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output"); +} + 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(); @@ -236,11 +276,8 @@ fn snapshot_script_failure_exits_non_zero() { #[test] fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() { let script = tmp("snap-ui-zoom.jsonl"); - std::fs::write( - &script, - format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})), - ) - .unwrap(); + std::fs::write(&script, format!("{}\n{}\n", json!({"method": "ui.zoomFactor", "params": {"factor": 1.1}}), json!({"method": "ui.inspect"}))) + .unwrap(); for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] { let out = tmp(&format!("snap-ui-zoom-{scale}.png")); let o = Command::new(BIN) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 9960fdd54..592d05cd1 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -169,6 +169,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen let restore_backend = backend.clone(); Services { picker: None, + pick_display_profile: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; // the photos' own names for the originals in the zip @@ -213,6 +214,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen // Face models need a folder to live in; the web has none. pick_model_file: None, save_preset_file: None, + pick_keyword_list: None, + save_keyword_list: None, pick_curve_preset_files: None, save_curve_preset_file: None, write: Some(Box::new(download)), diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index b08ae150b..4796740ea 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -113,6 +113,7 @@ impl WireJob { depth: lightcraft_engine::pipeline::OutputDepth::U8, proof: None, window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }), + display: None, } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 52a323289..f321bdb9b 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -293,6 +293,7 @@ impl Workers { rendered: Err(msg), loaded: None, quick: None, + display: None, }; g.done.push((b.slot, r, 0.0)); } @@ -379,6 +380,7 @@ impl Workers { rendered, loaded: None, quick: None, + display: None, }, ms, )); @@ -431,6 +433,7 @@ impl RenderOffload for Workers { rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, + display: None, }; g.done.push((slot, r, 0.0)); return None; diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 832b158b3..00665e87e 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -78,9 +78,17 @@ impl eframe::App for App { if let Err(e) = self.0.session.close_library() { log::error!("saving the library failed: {e}"); } + // last, with everything saved: no render may be inside the GPU driver when the process ends + if !self.0.shutdown(EXIT_WAIT) { + log::warn!("quit: background work was still running after {EXIT_WAIT:?}"); + } } } +/// How long quitting waits for renders that are running (issue #620). A GPU render takes well under +/// a second; anything slower gives way to quitting. +const EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(2); + /// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12 /// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136: /// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12 @@ -133,6 +141,23 @@ fn gpu_crash_notice(what: &str) -> String { ) } +/// 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 { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + let user = |rel: &str| home.as_ref().map(|h| h.join(rel)); + let candidates: Vec> = if cfg!(target_os = "macos") { + vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())] + } else if cfg!(windows) { + let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + vec![Some(root.join("System32").join("spool").join("drivers").join("color"))] + } else { + let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share")); + vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())] + }; + candidates.into_iter().flatten().find(|d| d.is_dir()) +} + fn config_dir() -> Option { lightcraft_engine::config::config_dir() } @@ -464,6 +489,15 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .map(|p| vec![p.to_string_lossy().to_string()]) .unwrap_or_default() })), + pick_display_profile: Some(Box::new(|| { + let mut d = rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]); + if let Some(dir) = display_profile_dir() { + d = d.set_directory(dir); + } + d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default() + })), save_preset_file: Some(Box::new(|name: &str| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) @@ -472,6 +506,22 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .save_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_keyword_list: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Import Keywords")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt", "utf8"]) + .pick_file() + .map(|p| vec![p.to_string_lossy().to_string()]) + .unwrap_or_default() + })), + save_keyword_list: Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Export Keywords")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Keyword Lists"), &["txt"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), pick_curve_preset_files: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Import Point Curve Presets")) diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 30709cb12..7ade9dac2 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -12,8 +12,10 @@ 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/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 | | `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 | | `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 | @@ -23,6 +25,7 @@ covers a whole directory), or if a licence file referenced in the Licence column | `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 | +| `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 | 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/ILCE-7CR.json b/assets/camera-profiles/ILCE-7CR.json new file mode 100644 index 000000000..3534f05c2 --- /dev/null +++ b/assets/camera-profiles/ILCE-7CR.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "ILCE-7CR", + "files": 125, + "samples": 448230, + "matrix": [ + [1.1094755401970307, -0.1814643173262276, 0.07642328704815071], + [-0.0353042898479492, 1.1462523170955077, -0.11812642489032932], + [-0.08133079077168937, -0.8682697273084319, 2.0120289809143364] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [1.6383686, 1.0720526, 1.0], + [-3.1825573, 1.0282102, 1.0], + [-3.0505095, 1.0160933, 1.0], + [-0.6544773, 1.0011821, 1.0], + [0.0, 1.0, 1.0], + [0.7573091, 1.0635676, 1.0], + [-3.6906753, 1.0049208, 1.0], + [-3.9162223, 0.98691005, 1.0], + [-1.7661046, 0.9845228, 1.0], + [0.0, 1.0, 1.0], + [1.3244245, 1.0702444, 1.0], + [-3.7783418, 1.0267969, 1.0], + [-4.208394, 0.98504055, 1.0], + [-3.8247044, 0.95117354, 1.0], + [0.0, 1.0, 1.0], + [0.7408551, 1.0626208, 1.0], + [-4.5483084, 1.0503119, 1.0], + [-5.022255, 1.0133994, 1.0], + [-4.9868593, 0.9531773, 1.0], + [0.0, 1.0, 1.0], + [2.1140733, 1.0510494, 1.0], + [-4.3416905, 1.0382092, 1.0], + [-5.6782136, 1.0137324, 1.0], + [-6.408746, 0.95611, 1.0], + [0.0, 1.0, 1.0], + [1.8071455, 1.0453869, 1.0], + [-4.1650367, 1.0307838, 1.0], + [-7.1355047, 1.0019747, 1.0], + [-8.950974, 0.94559556, 1.0], + [0.0, 1.0, 1.0], + [1.9337237, 1.0438595, 1.0], + [-4.7190723, 1.0019213, 1.0], + [-8.449162, 0.98546976, 1.0], + [-9.80414, 0.9475938, 1.0], + [0.0, 1.0, 1.0], + [1.8035413, 1.0596792, 1.0], + [-4.483865, 0.99361634, 1.0], + [-9.145701, 0.95965594, 1.0], + [-12.007559, 0.9046417, 1.0], + [0.0, 1.0, 1.0], + [1.1054618, 1.0610611, 1.0], + [-3.750633, 1.0037059, 1.0], + [-8.3648205, 0.9508676, 1.0], + [-12.104278, 0.89305633, 1.0], + [0.0, 1.0, 1.0], + [2.3433983, 1.0385724, 1.0], + [-1.8320823, 0.96856654, 1.0], + [-6.4716563, 0.89340544, 1.0], + [-13.182256, 0.79202217, 1.0], + [0.0, 1.0, 1.0], + [4.1155825, 1.0546852, 1.0], + [0.2504126, 0.95593035, 1.0], + [-4.421105, 0.86601037, 1.0], + [-11.81684, 0.76859766, 1.0], + [0.0, 1.0, 1.0], + [5.193353, 1.100501, 1.0], + [2.245616, 0.9877438, 1.0], + [-1.3484466, 0.89190966, 1.0], + [-5.656248, 0.79824746, 1.0], + [0.0, 1.0, 1.0], + [5.539407, 1.1472836, 1.0], + [3.5321336, 1.0246028, 1.0], + [1.6943812, 0.92413193, 1.0], + [0.06802155, 0.8097732, 1.0], + [0.0, 1.0, 1.0], + [5.217841, 1.1450701, 1.0], + [4.3687263, 1.0359106, 1.0], + [3.8731997, 0.92895746, 1.0], + [3.6518888, 0.81207585, 1.0], + [0.0, 1.0, 1.0], + [3.9854875, 1.1393564, 1.0], + [4.353656, 1.0462686, 1.0], + [4.8320484, 0.9382486, 1.0], + [5.2603836, 0.83231425, 1.0], + [0.0, 1.0, 1.0], + [3.4905012, 1.1162536, 1.0], + [4.399138, 1.0209066, 1.0], + [5.125341, 0.93585205, 1.0], + [5.7167406, 0.8500491, 1.0], + [0.0, 1.0, 1.0], + [3.456529, 1.0933266, 1.0], + [4.4567842, 1.0046545, 1.0], + [5.3395023, 0.9265063, 1.0], + [6.239501, 0.8413839, 1.0], + [0.0, 1.0, 1.0], + [3.3244715, 1.0775917, 1.0], + [4.413473, 1.0032461, 1.0], + [5.2259183, 0.93235445, 1.0], + [5.996756, 0.8481907, 1.0], + [0.0, 1.0, 1.0], + [2.7977114, 1.0930682, 1.0], + [3.9809735, 1.0265673, 1.0], + [4.826016, 0.964025, 1.0], + [5.9678993, 0.8728676, 1.0], + [0.0, 1.0, 1.0], + [2.3974957, 1.1096336, 1.0], + [3.507613, 1.0540297, 1.0], + [4.679865, 0.94909227, 1.0], + [6.176524, 0.80889976, 1.0], + [0.0, 1.0, 1.0], + [1.6225548, 1.1412165, 1.0], + [3.2221508, 1.0394865, 1.0], + [4.731801, 0.89790475, 1.0], + [5.0452776, 0.8134008, 1.0], + [0.0, 1.0, 1.0], + [0.92599183, 1.1604693, 1.0], + [3.414574, 1.0124736, 1.0], + [3.7358258, 0.9165049, 1.0], + [2.2723522, 0.91374624, 1.0], + [0.0, 1.0, 1.0], + [-0.45341417, 1.168921, 1.0], + [1.5084614, 1.0431032, 1.0], + [0.8854018, 0.9520788, 1.0], + [-1.05283, 0.94668686, 1.0], + [0.0, 1.0, 1.0], + [-1.3174335, 1.1757046, 1.0], + [0.5896504, 1.061128, 1.0], + [-0.31401426, 0.9736818, 1.0], + [-1.8383517, 0.95333457, 1.0], + [0.0, 1.0, 1.0], + [-2.1522639, 1.1637881, 1.0], + [-1.4493614, 1.0722672, 1.0], + [-1.4632736, 1.0168622, 1.0], + [-0.29180732, 0.99931026, 1.0], + [0.0, 1.0, 1.0], + [-2.7037742, 1.1205176, 1.0], + [-2.8786352, 1.0513667, 1.0], + [-1.286718, 1.0099841, 1.0], + [-0.06583492, 0.99989015, 1.0], + [0.0, 1.0, 1.0], + [-4.2293205, 1.1057495, 1.0], + [-5.281706, 1.0493338, 1.0], + [-2.4175398, 1.0138595, 1.0], + [-0.047981504, 0.99992627, 1.0], + [0.0, 1.0, 1.0], + [-5.8813257, 1.1112839, 1.0], + [-7.088055, 1.0551285, 1.0], + [-3.9832814, 1.0086114, 1.0], + [-0.17585999, 0.9988311, 1.0], + [0.0, 1.0, 1.0], + [-6.761259, 1.1026558, 1.0], + [-8.000364, 1.0663072, 1.0], + [-4.1537356, 1.0163689, 1.0], + [-0.13010694, 0.99951726, 1.0], + [0.0, 1.0, 1.0], + [-8.351095, 1.0976008, 1.0], + [-8.466059, 1.0682116, 1.0], + [-3.5471215, 1.0211279, 1.0], + [-0.0509097, 1.0001352, 1.0], + [0.0, 1.0, 1.0], + [-9.057853, 1.1055348, 1.0], + [-8.974651, 1.0689155, 1.0], + [-3.2166526, 1.0227399, 1.0], + [-0.032699484, 1.0002922, 1.0], + [0.0, 1.0, 1.0], + [-9.427814, 1.1105726, 1.0], + [-9.507676, 1.0691713, 1.0], + [-3.295734, 1.0189606, 1.0], + [-0.034528155, 1.0002044, 1.0], + [0.0, 1.0, 1.0], + [-9.442637, 1.1099377, 1.0], + [-10.499484, 1.0757492, 1.0], + [-4.3815494, 1.0181382, 1.0], + [-0.07561345, 1.0000867, 1.0], + [0.0, 1.0, 1.0], + [-9.983269, 1.114439, 1.0], + [-10.951814, 1.0815339, 1.0], + [-4.2156606, 1.0171853, 1.0], + [-0.08563397, 0.99997413, 1.0], + [0.0, 1.0, 1.0], + [-10.670809, 1.1166714, 1.0], + [-11.719119, 1.0770913, 1.0], + [-4.6209064, 1.0083113, 1.0], + [-0.12722205, 0.9996538, 1.0], + [0.0, 1.0, 1.0], + [-12.437358, 1.1277313, 1.0], + [-12.732603, 1.0851367, 1.0], + [-4.236061, 1.0089667, 1.0], + [-0.113364056, 0.9994685, 1.0], + [0.0, 1.0, 1.0], + [-12.538394, 1.1138527, 1.0], + [-11.907458, 1.0645828, 1.0], + [-4.889425, 0.9901681, 1.0], + [-0.30379632, 0.99794984, 1.0], + [0.0, 1.0, 1.0], + [-12.718707, 1.0802225, 1.0], + [-12.242265, 1.0252401, 1.0], + [-6.184896, 0.96578616, 1.0], + [-0.6155264, 0.99502194, 1.0], + [0.0, 1.0, 1.0], + [-14.020359, 1.0553012, 1.0], + [-13.098407, 1.0011256, 1.0], + [-7.327469, 0.9492975, 1.0], + [-0.9094673, 0.9916255, 1.0], + [0.0, 1.0, 1.0], + [-15.101152, 1.0212511, 1.0], + [-12.824179, 0.96607465, 1.0], + [-10.019363, 0.9155735, 1.0], + [-1.7772533, 0.9820373, 1.0], + [0.0, 1.0, 1.0], + [-14.286782, 0.9806954, 1.0], + [-9.569648, 0.94380945, 1.0], + [-9.857059, 0.9003953, 1.0], + [-2.1718888, 0.9759639, 1.0], + [0.0, 1.0, 1.0], + [-13.73097, 0.94591624, 1.0], + [-11.556911, 0.921234, 1.0], + [-12.93851, 0.8641401, 1.0], + [-3.652711, 0.9552426, 1.0], + [0.0, 1.0, 1.0], + [-16.801582, 0.8864382, 1.0], + [-11.193753, 0.9149052, 1.0], + [-9.738617, 0.8872918, 1.0], + [-2.5534081, 0.9664013, 1.0], + [0.0, 1.0, 1.0], + [-17.101755, 0.84524596, 1.0], + [-9.365246, 0.91499794, 1.0], + [-4.452413, 0.9442718, 1.0], + [-0.7637209, 0.989576, 1.0], + [0.0, 1.0, 1.0], + [-18.14948, 0.8175313, 1.0], + [-11.684059, 0.8936882, 1.0], + [-3.2155685, 0.9607375, 1.0], + [-0.26556945, 0.9955721, 1.0], + [0.0, 1.0, 1.0], + [-16.426699, 0.8100732, 1.0], + [-9.782718, 0.9018959, 1.0], + [-4.339177, 0.96653545, 1.0], + [-0.41509473, 0.99594533, 1.0], + [0.0, 1.0, 1.0], + [-10.315517, 0.84992623, 1.0], + [-5.747398, 0.9413663, 1.0], + [-3.1746607, 0.982243, 1.0], + [-0.5927395, 0.994371, 1.0], + [0.0, 1.0, 1.0], + [-6.9678645, 0.8809627, 1.0], + [-3.7169914, 0.9728603, 1.0], + [-0.7432635, 1.0332607, 1.0], + [0.729794, 1.0225081, 1.0], + [0.0, 1.0, 1.0], + [-5.2035117, 0.84641093, 1.0], + [-1.5942934, 0.9789916, 1.0], + [1.2482002, 1.0807693, 1.0], + [1.347859, 1.064622, 1.0], + [0.0, 1.0, 1.0], + [-3.1986942, 0.7543623, 1.0], + [0.8410356, 0.938187, 1.0], + [2.4551218, 1.095957, 1.0], + [2.1389015, 1.0985862, 1.0], + [0.0, 1.0, 1.0], + [-0.88384134, 0.7095005, 1.0], + [1.9973824, 0.93200374, 1.0], + [2.549452, 1.0998962, 1.0], + [2.047063, 1.0879719, 1.0], + [0.0, 1.0, 1.0], + [0.7609332, 0.6956258, 1.0], + [2.31692, 0.9102155, 1.0], + [2.647937, 1.0628512, 1.0], + [3.2615342, 1.036636, 1.0], + [0.0, 1.0, 1.0], + [2.2741787, 0.69057876, 1.0], + [3.5142605, 0.88028973, 1.0], + [4.4796543, 1.0268018, 1.0], + [4.20299, 1.024853, 1.0], + [0.0, 1.0, 1.0], + [5.380194, 0.69558483, 1.0], + [5.080697, 0.882952, 1.0], + [6.105996, 1.0175306, 1.0], + [5.514054, 1.0149113, 1.0], + [0.0, 1.0, 1.0], + [9.913718, 0.69718885, 1.0], + [6.5240126, 0.8923008, 1.0], + [8.154414, 1.0078559, 1.0], + [6.6946135, 1.0048004, 1.0], + [0.0, 1.0, 1.0], + [13.040619, 0.6963414, 1.0], + [8.325149, 0.912086, 1.0], + [9.543625, 1.0013618, 1.0], + [6.4392266, 0.99676377, 1.0], + [0.0, 1.0, 1.0], + [14.4534025, 0.727793, 1.0], + [8.82926, 0.949796, 1.0], + [6.9925814, 1.0022112, 1.0], + [2.9664295, 0.99772435, 1.0], + [0.0, 1.0, 1.0], + [15.132328, 0.78525466, 1.0], + [10.728416, 0.9771031, 1.0], + [7.899672, 0.9900357, 1.0], + [2.4584854, 0.9965681, 1.0], + [0.0, 1.0, 1.0], + [16.370947, 0.8516936, 1.0], + [14.804218, 1.0220509, 1.0], + [7.60627, 1.0105268, 1.0], + [0.47612935, 0.9996986, 1.0], + [0.0, 1.0, 1.0], + [15.766772, 0.93907374, 1.0], + [16.010454, 1.051158, 1.0], + [7.589318, 1.0160706, 1.0], + [0.24546027, 1.000115, 1.0], + [0.0, 1.0, 1.0], + [14.457747, 0.9682619, 1.0], + [13.106996, 1.0823271, 1.0], + [5.0127563, 1.0306457, 1.0], + [0.11494827, 1.0005567, 1.0], + [0.0, 1.0, 1.0], + [13.896352, 0.95646006, 1.0], + [10.509385, 1.0747057, 1.0], + [4.687772, 1.0336343, 1.0], + [0.18767904, 1.0010031, 1.0], + [0.0, 1.0, 1.0], + [12.871006, 0.94392693, 1.0], + [8.716992, 1.0580047, 1.0], + [4.032118, 1.0331796, 1.0], + [0.19235706, 1.0013452, 1.0], + [0.0, 1.0, 1.0], + [12.761354, 0.94344586, 1.0], + [4.8192053, 1.0303724, 1.0], + [-0.55819005, 1.0258765, 1.0], + [-0.18075792, 1.0020537, 1.0], + [0.0, 1.0, 1.0], + [11.323022, 0.95529723, 1.0], + [3.8540874, 1.0221503, 1.0], + [-1.5989528, 1.0256935, 1.0], + [-0.31334943, 1.0026582, 1.0], + [0.0, 1.0, 1.0], + [9.946704, 0.988154, 1.0], + [2.8923109, 1.0258386, 1.0], + [-1.5042264, 1.0218962, 1.0], + [-0.26236275, 1.0021061, 1.0], + [0.0, 1.0, 1.0], + [9.115504, 0.9980661, 1.0], + [1.3947439, 1.0351801, 1.0], + [-1.7974052, 1.0161659, 1.0], + [-0.24226402, 1.0010152, 1.0], + [0.0, 1.0, 1.0], + [7.536506, 1.0020467, 1.0], + [0.87553304, 1.0329614, 1.0], + [0.18591607, 1.0122479, 1.0], + [0.054326985, 1.0008035, 1.0], + [0.0, 1.0, 1.0], + [6.8057537, 1.0455284, 1.0], + [1.8502842, 1.0506709, 1.0], + [0.7893407, 1.0182511, 1.0], + [0.09190431, 1.0011369, 1.0], + [0.0, 1.0, 1.0], + [6.130396, 1.0462483, 1.0], + [0.7220753, 1.0326569, 1.0], + [-0.40912512, 1.0052713, 1.0], + [0.03908602, 1.0007522, 1.0], + [0.0, 1.0, 1.0], + [6.2550654, 1.05479, 1.0], + [1.3151088, 1.0173345, 1.0], + [0.43053803, 1.0046135, 1.0], + [0.23496991, 1.0016526, 1.0], + [0.0, 1.0, 1.0], + [4.200835, 1.041949, 1.0], + [-0.12719084, 0.97952026, 1.0], + [0.40004775, 0.9858662, 1.0], + [0.089090385, 1.0008289, 1.0], + [0.0, 1.0, 1.0], + [1.1867458, 1.063474, 1.0], + [-3.518081, 1.0572506, 1.0], + [-3.6311622, 1.0408844, 1.0], + [-1.250096, 1.0127242, 1.0], + [0.0, 1.0, 1.0], + [0.11487997, 1.055631, 1.0], + [-3.8922422, 1.0360129, 1.0], + [-4.149058, 0.99983233, 1.0], + [-2.9244108, 0.9689904, 1.0], + [0.0, 1.0, 1.0], + [1.1612215, 1.0692844, 1.0], + [-3.87592, 1.0401592, 1.0], + [-4.3422823, 0.97830665, 1.0], + [-3.9827328, 0.9330193, 1.0], + [0.0, 1.0, 1.0], + [1.6057762, 1.0657002, 1.0], + [-4.05366, 1.0526804, 1.0], + [-4.907047, 0.98893243, 1.0], + [-4.8015065, 0.93376106, 1.0], + [0.0, 1.0, 1.0], + [1.9405156, 1.0538071, 1.0], + [-3.854631, 1.0372756, 1.0], + [-5.677292, 1.008557, 1.0], + [-6.4605684, 0.95010316, 1.0], + [0.0, 1.0, 1.0], + [0.84950966, 1.0556635, 1.0], + [-3.8397493, 1.0464863, 1.0], + [-6.724636, 0.9974292, 1.0], + [-9.358037, 0.9205249, 1.0], + [0.0, 1.0, 1.0], + [1.36474, 1.0466326, 1.0], + [-4.358288, 1.0096835, 1.0], + [-8.078475, 0.96894723, 1.0], + [-10.280892, 0.91327274, 1.0], + [0.0, 1.0, 1.0], + [1.1897643, 1.0460767, 1.0], + [-4.8276143, 0.9861743, 1.0], + [-8.967868, 0.9454565, 1.0], + [-11.58099, 0.89431435, 1.0], + [0.0, 1.0, 1.0], + [0.8428742, 1.0415783, 1.0], + [-4.2447996, 0.9901131, 1.0], + [-8.640936, 0.9449217, 1.0], + [-11.831331, 0.8849682, 1.0], + [0.0, 1.0, 1.0], + [1.8116823, 1.0284086, 1.0], + [-2.5288608, 0.97493637, 1.0], + [-6.911547, 0.9111893, 1.0], + [-13.192846, 0.79674995, 1.0], + [0.0, 1.0, 1.0], + [3.2551363, 1.0582069, 1.0], + [-0.61379474, 0.9854093, 1.0], + [-5.207653, 0.885589, 1.0], + [-12.072603, 0.7643964, 1.0], + [0.0, 1.0, 1.0], + [4.0988145, 1.1001511, 1.0], + [1.3202463, 1.014535, 1.0], + [-2.0446327, 0.91062075, 1.0], + [-6.382716, 0.7884341, 1.0], + [0.0, 1.0, 1.0], + [4.772627, 1.1340997, 1.0], + [2.6516492, 1.0298927, 1.0], + [1.0751725, 0.93674093, 1.0], + [-0.6240697, 0.80116963, 1.0], + [0.0, 1.0, 1.0], + [4.6609416, 1.1409346, 1.0], + [3.6919625, 1.0409919, 1.0], + [3.1592674, 0.94452727, 1.0], + [2.8074346, 0.81181794, 1.0], + [0.0, 1.0, 1.0], + [3.88111, 1.1356293, 1.0], + [3.934658, 1.0424132, 1.0], + [4.262655, 0.94446814, 1.0], + [4.659253, 0.8268726, 1.0], + [0.0, 1.0, 1.0], + [3.2726362, 1.1170003, 1.0], + [3.9152527, 1.0194097, 1.0], + [4.7911615, 0.9256793, 1.0], + [5.6844645, 0.8277047, 1.0], + [0.0, 1.0, 1.0], + [2.968466, 1.084938, 1.0], + [3.864402, 1.0008085, 1.0], + [4.9878488, 0.9206543, 1.0], + [6.272722, 0.8304063, 1.0], + [0.0, 1.0, 1.0], + [2.805873, 1.0721347, 1.0], + [3.7892249, 1.0025132, 1.0], + [4.7126594, 0.937998, 1.0], + [5.7331266, 0.8633941, 1.0], + [0.0, 1.0, 1.0], + [2.439447, 1.0980592, 1.0], + [3.5134554, 1.0268866, 1.0], + [4.373185, 0.973018, 1.0], + [5.2375226, 0.9141984, 1.0], + [0.0, 1.0, 1.0], + [2.122798, 1.1300637, 1.0], + [3.0972943, 1.0678729, 1.0], + [4.0227356, 0.99388194, 1.0], + [4.4452977, 0.8434974, 1.0], + [0.0, 1.0, 1.0], + [1.4539274, 1.1699649, 1.0], + [2.7342727, 1.0873699, 1.0], + [4.0375504, 0.9513469, 1.0], + [2.912934, 0.7801174, 1.0], + [0.0, 1.0, 1.0], + [0.2035671, 1.2061948, 1.0], + [2.204418, 1.0808513, 1.0], + [3.2734146, 0.95607066, 1.0], + [3.3829432, 0.8604104, 1.0], + [0.0, 1.0, 1.0], + [-1.069349, 1.2016822, 1.0], + [0.78204316, 1.0802776, 1.0], + [0.8939021, 0.9876918, 1.0], + [0.9314171, 0.9479382, 1.0], + [0.0, 1.0, 1.0], + [-2.2317412, 1.2042382, 1.0], + [-0.8626396, 1.0817081, 1.0], + [-1.1587913, 1.0106043, 1.0], + [-0.7471611, 0.9841788, 1.0], + [0.0, 1.0, 1.0], + [-3.6874542, 1.1916536, 1.0], + [-2.8666534, 1.0680176, 1.0], + [-3.0523398, 1.0200123, 1.0], + [-0.8098775, 1.0000143, 1.0], + [0.0, 1.0, 1.0], + [-4.6867037, 1.1576309, 1.0], + [-4.1102524, 1.0329399, 1.0], + [-3.0036724, 0.9886833, 1.0], + [-0.33008483, 0.99551946, 1.0], + [0.0, 1.0, 1.0], + [-6.2138457, 1.1177778, 1.0], + [-5.5909715, 1.0219685, 1.0], + [-3.883835, 0.9935609, 1.0], + [-0.19700186, 0.9980648, 1.0], + [0.0, 1.0, 1.0], + [-7.703151, 1.118376, 1.0], + [-7.2529035, 1.0356544, 1.0], + [-5.5993958, 1.0061052, 1.0], + [-0.36084718, 0.99833083, 1.0], + [0.0, 1.0, 1.0], + [-9.133752, 1.1199366, 1.0], + [-9.163867, 1.0599968, 1.0], + [-6.685689, 1.0225968, 1.0], + [-0.3279509, 0.9992778, 1.0], + [0.0, 1.0, 1.0], + [-10.304704, 1.1167951, 1.0], + [-9.944268, 1.0687343, 1.0], + [-6.581675, 1.0340862, 1.0], + [-0.1731145, 1.0005047, 1.0], + [0.0, 1.0, 1.0], + [-11.287525, 1.12426, 1.0], + [-10.652732, 1.0709195, 1.0], + [-6.425803, 1.0336635, 1.0], + [-0.1326214, 1.0005356, 1.0], + [0.0, 1.0, 1.0], + [-11.926306, 1.1344961, 1.0], + [-11.767215, 1.0815933, 1.0], + [-6.9073353, 1.033758, 1.0], + [-0.15162425, 1.0003935, 1.0], + [0.0, 1.0, 1.0], + [-12.356211, 1.1320031, 1.0], + [-12.448733, 1.087039, 1.0], + [-7.868196, 1.0303723, 1.0], + [-0.21782555, 1.0001445, 1.0], + [0.0, 1.0, 1.0], + [-12.909969, 1.1390688, 1.0], + [-12.794445, 1.0962518, 1.0], + [-7.4221215, 1.0277627, 1.0], + [-0.24051952, 0.9994796, 1.0], + [0.0, 1.0, 1.0], + [-14.318492, 1.1441628, 1.0], + [-14.100717, 1.0943929, 1.0], + [-8.575853, 1.009897, 1.0], + [-0.41431823, 0.9982393, 1.0], + [0.0, 1.0, 1.0], + [-15.74911, 1.1528009, 1.0], + [-15.211963, 1.1008726, 1.0], + [-8.026304, 0.99996704, 1.0], + [-0.47660625, 0.9950099, 1.0], + [0.0, 1.0, 1.0], + [-16.241463, 1.1295997, 1.0], + [-15.810595, 1.066377, 1.0], + [-10.653159, 0.9577683, 1.0], + [-1.2817223, 0.9887495, 1.0], + [0.0, 1.0, 1.0], + [-17.143677, 1.0890604, 1.0], + [-17.320019, 1.0180693, 1.0], + [-14.736028, 0.90419865, 1.0], + [-3.1021247, 0.97202545, 1.0], + [0.0, 1.0, 1.0], + [-18.748793, 1.0463697, 1.0], + [-18.46616, 0.98106045, 1.0], + [-18.009438, 0.86111045, 1.0], + [-5.893209, 0.94255584, 1.0], + [0.0, 1.0, 1.0], + [-19.686289, 0.996309, 1.0], + [-19.95499, 0.901589, 1.0], + [-23.705408, 0.78339463, 1.0], + [-13.599181, 0.862805, 1.0], + [0.0, 1.0, 1.0], + [-19.272419, 0.9451124, 1.0], + [-18.105583, 0.8663253, 1.0], + [-23.313438, 0.7696128, 1.0], + [-14.894453, 0.842119, 1.0], + [0.0, 1.0, 1.0], + [-18.445044, 0.90566933, 1.0], + [-17.009562, 0.85194093, 1.0], + [-23.163944, 0.7514455, 1.0], + [-18.454296, 0.7895318, 1.0], + [0.0, 1.0, 1.0], + [-19.975847, 0.8582838, 1.0], + [-15.432948, 0.85963035, 1.0], + [-21.027409, 0.75946647, 1.0], + [-18.194445, 0.78112334, 1.0], + [0.0, 1.0, 1.0], + [-18.885874, 0.83879834, 1.0], + [-14.115562, 0.8839908, 1.0], + [-12.019917, 0.8720784, 1.0], + [-6.3177505, 0.91974896, 1.0], + [0.0, 1.0, 1.0], + [-17.701115, 0.8398532, 1.0], + [-14.244609, 0.89724517, 1.0], + [-9.12698, 0.9179684, 1.0], + [-1.9648052, 0.9748977, 1.0], + [0.0, 1.0, 1.0], + [-16.505676, 0.80830556, 1.0], + [-10.482741, 0.91814256, 1.0], + [-6.0856237, 0.97157246, 1.0], + [-1.7848674, 0.98539186, 1.0], + [0.0, 1.0, 1.0], + [-11.111074, 0.84318155, 1.0], + [-6.120855, 0.96219766, 1.0], + [-3.149391, 1.02109, 1.0], + [-0.97741336, 1.0116717, 1.0], + [0.0, 1.0, 1.0], + [-7.5704823, 0.8942756, 1.0], + [-3.2543333, 1.0210055, 1.0], + [-0.5860294, 1.0834074, 1.0], + [0.870136, 1.0914137, 1.0], + [0.0, 1.0, 1.0], + [-6.0229716, 0.8704741, 1.0], + [-1.4675226, 1.0396374, 1.0], + [0.57677406, 1.1029227, 1.0], + [1.422931, 1.1059595, 1.0], + [0.0, 1.0, 1.0], + [-3.5032904, 0.7701063, 1.0], + [0.7988452, 0.982442, 1.0], + [2.1533048, 1.102169, 1.0], + [2.2210178, 1.1071599, 1.0], + [0.0, 1.0, 1.0], + [0.029193172, 0.72968274, 1.0], + [2.5515428, 0.9443575, 1.0], + [2.4624462, 1.1000748, 1.0], + [2.2837563, 1.1011144, 1.0], + [0.0, 1.0, 1.0], + [1.7426748, 0.71043026, 1.0], + [3.054676, 0.9081306, 1.0], + [2.6141508, 1.0664026, 1.0], + [2.980883, 1.0584391, 1.0], + [0.0, 1.0, 1.0], + [3.6768913, 0.6937622, 1.0], + [4.527743, 0.8812177, 1.0], + [4.8806653, 1.0332351, 1.0], + [4.967902, 1.0312556, 1.0], + [0.0, 1.0, 1.0], + [7.1945524, 0.68730164, 1.0], + [6.950681, 0.8867778, 1.0], + [7.3334575, 1.0227884, 1.0], + [7.0962567, 1.0217508, 1.0], + [0.0, 1.0, 1.0], + [11.6981535, 0.68517774, 1.0], + [9.424991, 0.89795345, 1.0], + [10.109896, 1.0150306, 1.0], + [9.631388, 1.0133693, 1.0], + [0.0, 1.0, 1.0], + [15.01595, 0.69489264, 1.0], + [10.674274, 0.93846947, 1.0], + [12.570046, 0.999178, 1.0], + [11.800817, 0.993752, 1.0], + [0.0, 1.0, 1.0], + [16.40186, 0.72178566, 1.0], + [13.150471, 0.9621032, 1.0], + [13.788712, 0.9881621, 1.0], + [12.145564, 0.9821678, 1.0], + [0.0, 1.0, 1.0], + [16.426535, 0.7873739, 1.0], + [15.013656, 0.98788464, 1.0], + [15.114616, 0.9857073, 1.0], + [10.671278, 0.97703195, 1.0], + [0.0, 1.0, 1.0], + [18.044664, 0.8680412, 1.0], + [18.143904, 1.0268604, 1.0], + [16.295511, 1.0147856, 1.0], + [5.056517, 0.9941207, 1.0], + [0.0, 1.0, 1.0], + [18.028666, 0.95773226, 1.0], + [18.875088, 1.0640182, 1.0], + [16.150946, 1.0385625, 1.0], + [3.1871507, 1.0035607, 1.0], + [0.0, 1.0, 1.0], + [16.514034, 1.0013099, 1.0], + [17.314789, 1.0905116, 1.0], + [14.560985, 1.0545624, 1.0], + [1.7983161, 1.0041636, 1.0], + [0.0, 1.0, 1.0], + [15.797172, 1.0078827, 1.0], + [14.955377, 1.0977542, 1.0], + [12.220257, 1.0560337, 1.0], + [1.3466339, 1.0037528, 1.0], + [0.0, 1.0, 1.0], + [14.565454, 0.98415726, 1.0], + [11.735246, 1.0906302, 1.0], + [8.986611, 1.0521289, 1.0], + [0.96998435, 1.0029405, 1.0], + [0.0, 1.0, 1.0], + [13.707696, 0.9740702, 1.0], + [9.109479, 1.0700653, 1.0], + [6.174766, 1.0449634, 1.0], + [0.61549, 1.0034261, 1.0], + [0.0, 1.0, 1.0], + [13.172035, 0.965594, 1.0], + [6.792434, 1.0554295, 1.0], + [2.0799077, 1.0371919, 1.0], + [-0.03451228, 1.002662, 1.0], + [0.0, 1.0, 1.0], + [12.510916, 0.9951767, 1.0], + [4.6987195, 1.0665245, 1.0], + [0.08861357, 1.0415522, 1.0], + [-0.20895422, 1.0027298, 1.0], + [0.0, 1.0, 1.0], + [10.848053, 0.9867255, 1.0], + [2.4717958, 1.0654173, 1.0], + [-1.2781724, 1.0430613, 1.0], + [-0.29540727, 1.0029501, 1.0], + [0.0, 1.0, 1.0], + [8.924775, 1.0128679, 1.0], + [1.6049622, 1.0838153, 1.0], + [0.04660962, 1.0468647, 1.0], + [0.06334232, 1.0034819, 1.0], + [0.0, 1.0, 1.0], + [7.795764, 1.0535507, 1.0], + [1.7419995, 1.088198, 1.0], + [-0.15147558, 1.0503129, 1.0], + [-0.0022487787, 1.0045427, 1.0], + [0.0, 1.0, 1.0], + [7.2572355, 1.0579109, 1.0], + [1.0977098, 1.0733509, 1.0], + [-1.2772944, 1.044092, 1.0], + [-0.13525482, 1.0073868, 1.0], + [0.0, 1.0, 1.0], + [6.364052, 1.0702623, 1.0], + [0.38560438, 1.0597174, 1.0], + [-0.880569, 1.0338639, 1.0], + [0.45450735, 1.0093911, 1.0], + [0.0, 1.0, 1.0], + [3.635617, 1.0665764, 1.0], + [-1.8541563, 1.0492157, 1.0], + [-2.224277, 1.0312802, 1.0], + [0.02060782, 1.0094924, 1.0], + [0.0, 1.0, 1.0], + [0.9177897, 0.931073, 1.0], + [-3.830959, 0.9463443, 1.0], + [-4.178485, 0.96985245, 1.0], + [-2.8674722, 0.9778141, 1.0], + [0.0, 1.0, 1.0], + [0.63912636, 0.92612, 1.0], + [-3.5380905, 0.9278758, 1.0], + [-4.304839, 0.9169423, 1.0], + [-3.5802007, 0.9137134, 1.0], + [0.0, 1.0, 1.0], + [1.754941, 0.92468625, 1.0], + [-2.9381945, 0.9232937, 1.0], + [-3.6206696, 0.90653074, 1.0], + [-3.333006, 0.8945919, 1.0], + [0.0, 1.0, 1.0], + [3.4640048, 0.9297938, 1.0], + [-1.9298265, 0.9331787, 1.0], + [-3.5713015, 0.9139552, 1.0], + [-3.4944124, 0.8915449, 1.0], + [0.0, 1.0, 1.0], + [3.2694256, 0.93911076, 1.0], + [-1.7281839, 0.9344968, 1.0], + [-4.068964, 0.9337624, 1.0], + [-3.7026005, 0.88743496, 1.0], + [0.0, 1.0, 1.0], + [2.443677, 0.9488072, 1.0], + [-2.1556156, 0.9397697, 1.0], + [-5.040308, 0.9141083, 1.0], + [-7.3046207, 0.8774791, 1.0], + [0.0, 1.0, 1.0], + [3.2289524, 0.9390356, 1.0], + [-2.256875, 0.87258524, 1.0], + [-5.24316, 0.86723936, 1.0], + [-8.016062, 0.860503, 1.0], + [0.0, 1.0, 1.0], + [2.379857, 0.9152337, 1.0], + [-3.06269, 0.88222355, 1.0], + [-5.274808, 0.8565716, 1.0], + [-6.253486, 0.83439857, 1.0], + [0.0, 1.0, 1.0], + [1.9447069, 0.8909187, 1.0], + [-2.1291292, 0.8753727, 1.0], + [-4.2053227, 0.8382535, 1.0], + [-4.9932523, 0.804721, 1.0], + [0.0, 1.0, 1.0], + [2.2031088, 0.883391, 1.0], + [-0.5427132, 0.8514603, 1.0], + [-2.1573637, 0.8162667, 1.0], + [-5.739983, 0.78099483, 1.0], + [0.0, 1.0, 1.0], + [3.0463758, 0.8902918, 1.0], + [0.4049588, 0.8463498, 1.0], + [-1.3030909, 0.8070003, 1.0], + [-5.3864274, 0.73747945, 1.0], + [0.0, 1.0, 1.0], + [3.5551093, 0.93288326, 1.0], + [1.5804863, 0.88684475, 1.0], + [-0.0010043167, 0.8088468, 1.0], + [-2.0880547, 0.7102155, 1.0], + [0.0, 1.0, 1.0], + [3.7656722, 0.9366638, 1.0], + [2.0004451, 0.89587283, 1.0], + [0.78522146, 0.8225704, 1.0], + [-0.7193246, 0.7130292, 1.0], + [0.0, 1.0, 1.0], + [3.447219, 0.95061296, 1.0], + [2.4834387, 0.91706544, 1.0], + [1.561155, 0.851608, 1.0], + [0.53629506, 0.7400539, 1.0], + [0.0, 1.0, 1.0], + [2.7838557, 0.9778509, 1.0], + [2.667703, 0.94507015, 1.0], + [2.5998428, 0.87076056, 1.0], + [2.1060734, 0.76656604, 1.0], + [0.0, 1.0, 1.0], + [1.9395797, 0.98501194, 1.0], + [2.645288, 0.9442791, 1.0], + [3.7864566, 0.86861855, 1.0], + [4.5985446, 0.77926284, 1.0], + [0.0, 1.0, 1.0], + [1.8749794, 0.9834793, 1.0], + [2.6313577, 0.9403016, 1.0], + [3.6338875, 0.8868182, 1.0], + [4.784866, 0.8023069, 1.0], + [0.0, 1.0, 1.0], + [1.8682514, 0.97530806, 1.0], + [2.5901785, 0.9404073, 1.0], + [3.0052283, 0.905837, 1.0], + [2.0069096, 0.83484143, 1.0], + [0.0, 1.0, 1.0], + [1.4727961, 1.0080808, 1.0], + [2.3305118, 0.9540467, 1.0], + [2.282054, 0.91564345, 1.0], + [-7.6290994, 0.69604313, 1.0], + [0.0, 1.0, 1.0], + [1.0637859, 1.0516135, 1.0], + [1.7671573, 0.9879856, 1.0], + [-5.2584343, 0.73675436, 1.0], + [-16.342901, 0.5216483, 1.0], + [0.0, 1.0, 1.0], + [0.61982626, 1.0828284, 1.0], + [1.5899999, 0.95572406, 1.0], + [-5.914253, 0.5874747, 1.0], + [-8.813023, 0.507078, 1.0], + [0.0, 1.0, 1.0], + [-0.5224679, 1.0920671, 1.0], + [2.2305388, 0.914912, 1.0], + [8.181367, 0.6633539, 1.0], + [11.847236, 0.5297007, 1.0], + [0.0, 1.0, 1.0], + [-0.8650954, 1.0546236, 1.0], + [2.5084958, 0.8949213, 1.0], + [8.434784, 0.7660728, 1.0], + [19.517859, 0.6189292, 1.0], + [0.0, 1.0, 1.0], + [-0.33615965, 0.9680921, 1.0], + [1.9523638, 0.8709517, 1.0], + [3.7503355, 0.8401423, 1.0], + [5.316759, 0.87355274, 1.0], + [0.0, 1.0, 1.0], + [-1.4475873, 0.99367666, 1.0], + [0.3394638, 0.9137543, 1.0], + [1.7758911, 0.8927394, 1.0], + [1.1283293, 0.9674458, 1.0], + [0.0, 1.0, 1.0], + [-2.4284496, 1.0324663, 1.0], + [-1.4436898, 0.9559075, 1.0], + [-0.31089765, 0.9398312, 1.0], + [0.15274782, 0.99124306, 1.0], + [0.0, 1.0, 1.0], + [-2.9135323, 0.9894693, 1.0], + [-1.0511459, 0.9277451, 1.0], + [0.45192885, 0.9280664, 1.0], + [0.32532325, 0.98979396, 1.0], + [0.0, 1.0, 1.0], + [-3.6640732, 0.9995134, 1.0], + [-1.4445381, 0.94103074, 1.0], + [-0.45791662, 0.9589122, 1.0], + [0.080993034, 0.9953546, 1.0], + [0.0, 1.0, 1.0], + [-3.9926722, 1.0097752, 1.0], + [-2.3710601, 0.9654012, 1.0], + [-0.9738827, 0.97923654, 1.0], + [-0.07195478, 0.99595565, 1.0], + [0.0, 1.0, 1.0], + [-4.377553, 0.9997677, 1.0], + [-2.9565587, 0.951771, 1.0], + [-1.253855, 0.97048193, 1.0], + [-0.08838272, 0.99680644, 1.0], + [0.0, 1.0, 1.0], + [-4.8839297, 0.9710823, 1.0], + [-2.0442789, 0.89748603, 1.0], + [-0.6975334, 0.93988526, 1.0], + [0.013153156, 0.9972744, 1.0], + [0.0, 1.0, 1.0], + [-5.2213855, 0.95285964, 1.0], + [-2.4940815, 0.8997929, 1.0], + [-0.48827216, 0.956785, 1.0], + [0.22012724, 0.9936011, 1.0], + [0.0, 1.0, 1.0], + [-6.361059, 0.9966289, 1.0], + [-4.30683, 0.97904444, 1.0], + [-0.99236, 0.99435294, 1.0], + [0.009188855, 0.99846935, 1.0], + [0.0, 1.0, 1.0], + [-8.2860985, 1.0472525, 1.0], + [-4.832397, 1.016574, 1.0], + [-1.5982567, 0.96859825, 1.0], + [-0.58968186, 0.9639687, 1.0], + [0.0, 1.0, 1.0], + [-8.645831, 1.0387725, 1.0], + [-4.62883, 0.99550915, 1.0], + [-2.1827693, 0.89403373, 1.0], + [-0.32529926, 0.9052658, 1.0], + [0.0, 1.0, 1.0], + [-8.275511, 1.0268661, 1.0], + [-6.3909574, 0.9760469, 1.0], + [-7.4211764, 0.8973423, 1.0], + [-2.6890829, 0.9495063, 1.0], + [0.0, 1.0, 1.0], + [-9.703486, 1.0061108, 1.0], + [-7.5075417, 0.95615023, 1.0], + [-7.846866, 0.88695526, 1.0], + [-3.1512766, 0.9450091, 1.0], + [0.0, 1.0, 1.0], + [-11.724224, 0.97924507, 1.0], + [-7.2378116, 0.9570293, 1.0], + [-6.0934024, 0.91340625, 1.0], + [-1.4091715, 0.97521883, 1.0], + [0.0, 1.0, 1.0], + [-15.354833, 0.944077, 1.0], + [-11.498621, 0.93437237, 1.0], + [-10.131048, 0.8965729, 1.0], + [-2.5142438, 0.97270924, 1.0], + [0.0, 1.0, 1.0], + [-16.9717, 0.904382, 1.0], + [-15.424872, 0.87748027, 1.0], + [-19.262257, 0.8070707, 1.0], + [-8.481445, 0.91010606, 1.0], + [0.0, 1.0, 1.0], + [-16.93574, 0.86007816, 1.0], + [-14.099674, 0.8571838, 1.0], + [-18.867304, 0.7990837, 1.0], + [-9.067885, 0.89900285, 1.0], + [0.0, 1.0, 1.0], + [-17.21487, 0.795665, 1.0], + [-14.66645, 0.8218075, 1.0], + [-20.941486, 0.75604767, 1.0], + [-14.4985485, 0.8239577, 1.0], + [0.0, 1.0, 1.0], + [-19.005753, 0.758779, 1.0], + [-16.680302, 0.7971698, 1.0], + [-23.688643, 0.7191442, 1.0], + [-21.129248, 0.7400342, 1.0], + [0.0, 1.0, 1.0], + [-18.484707, 0.78127193, 1.0], + [-17.147287, 0.8205015, 1.0], + [-16.7867, 0.8153531, 1.0], + [-6.6663785, 0.8562561, 1.0], + [0.0, 1.0, 1.0], + [-17.904772, 0.7992509, 1.0], + [-16.290035, 0.8404375, 1.0], + [-14.898, 0.84211147, 1.0], + [-2.2072968, 0.9246411, 1.0], + [0.0, 1.0, 1.0], + [-17.892328, 0.7252675, 1.0], + [-12.422257, 0.84525913, 1.0], + [-7.915848, 0.90744674, 1.0], + [-2.4332101, 0.9394951, 1.0], + [0.0, 1.0, 1.0], + [-12.256403, 0.7670256, 1.0], + [-7.3728104, 0.89708215, 1.0], + [-4.5105395, 0.97277874, 1.0], + [-1.5832016, 0.9920798, 1.0], + [0.0, 1.0, 1.0], + [-8.016388, 0.830646, 1.0], + [-4.5538907, 0.95883, 1.0], + [-2.5789335, 1.0234452, 1.0], + [-1.105107, 1.0530177, 1.0], + [0.0, 1.0, 1.0], + [-7.20433, 0.7782197, 1.0], + [-3.208458, 0.96606797, 1.0], + [-1.2935421, 1.0479633, 1.0], + [-0.0024548925, 1.0702269, 1.0], + [0.0, 1.0, 1.0], + [-4.574243, 0.6783128, 1.0], + [-0.29323265, 0.85936415, 1.0], + [1.1714814, 1.0271277, 1.0], + [1.6805246, 1.0721738, 1.0], + [0.0, 1.0, 1.0], + [0.44121024, 0.69732547, 1.0], + [2.5033805, 0.8090156, 1.0], + [2.4892168, 1.0115892, 1.0], + [2.4337752, 1.0545831, 1.0], + [0.0, 1.0, 1.0], + [2.7189965, 0.7012635, 1.0], + [3.8152018, 0.79624814, 1.0], + [4.6650043, 1.0100449, 1.0], + [4.9573836, 1.0277257, 1.0], + [0.0, 1.0, 1.0], + [4.6361694, 0.6836669, 1.0], + [5.552024, 0.82980967, 1.0], + [7.0284324, 1.0110646, 1.0], + [7.0502753, 1.0152555, 1.0], + [0.0, 1.0, 1.0], + [7.356563, 0.66799754, 1.0], + [7.49933, 0.86984295, 1.0], + [8.87279, 1.0068405, 1.0], + [8.66629, 1.0076414, 1.0], + [0.0, 1.0, 1.0], + [10.443153, 0.6744798, 1.0], + [8.668258, 0.89632285, 1.0], + [10.57484, 0.9984911, 1.0], + [10.112442, 1.0011668, 1.0], + [0.0, 1.0, 1.0], + [12.3462305, 0.69375634, 1.0], + [8.875547, 0.9268088, 1.0], + [11.964839, 0.98318386, 1.0], + [11.041635, 0.98537666, 1.0], + [0.0, 1.0, 1.0], + [13.036472, 0.71343744, 1.0], + [11.375755, 0.9281431, 1.0], + [13.321662, 0.9727517, 1.0], + [11.939063, 0.9759464, 1.0], + [0.0, 1.0, 1.0], + [12.724641, 0.77864206, 1.0], + [12.92866, 0.9616247, 1.0], + [13.789892, 0.96263355, 1.0], + [10.826783, 0.966194, 1.0], + [0.0, 1.0, 1.0], + [14.19844, 0.8416513, 1.0], + [15.266627, 0.9758373, 1.0], + [14.160454, 0.96900314, 1.0], + [6.107243, 0.977027, 1.0], + [0.0, 1.0, 1.0], + [12.747447, 0.88937104, 1.0], + [15.23662, 0.99281013, 1.0], + [13.870695, 0.9845561, 1.0], + [3.245, 0.992735, 1.0], + [0.0, 1.0, 1.0], + [11.464138, 0.9069218, 1.0], + [13.882505, 0.99412805, 1.0], + [12.3678875, 0.984619, 1.0], + [2.4400496, 0.9936844, 1.0], + [0.0, 1.0, 1.0], + [10.054893, 0.9309123, 1.0], + [11.051042, 0.9889742, 1.0], + [9.503872, 0.9689598, 1.0], + [2.0153925, 0.98796266, 1.0], + [0.0, 1.0, 1.0], + [9.463208, 0.9101172, 1.0], + [7.3157954, 0.9752043, 1.0], + [6.0373473, 0.95434564, 1.0], + [1.4550027, 0.9813375, 1.0], + [0.0, 1.0, 1.0], + [7.941972, 0.91271096, 1.0], + [4.060063, 0.9565896, 1.0], + [2.8974419, 0.94200397, 1.0], + [0.63167155, 0.97762823, 1.0], + [0.0, 1.0, 1.0], + [7.7601333, 0.8894366, 1.0], + [1.375002, 0.936908, 1.0], + [-1.5859069, 0.9271758, 1.0], + [-0.71164757, 0.9791451, 1.0], + [0.0, 1.0, 1.0], + [8.1289625, 0.8806327, 1.0], + [-1.5395085, 0.93572843, 1.0], + [-5.483463, 0.9259951, 1.0], + [-1.7303535, 0.97720516, 1.0], + [0.0, 1.0, 1.0], + [8.060848, 0.89185596, 1.0], + [-2.4483445, 0.95255435, 1.0], + [-7.1547413, 0.9442576, 1.0], + [-2.1877682, 0.98242164, 1.0], + [0.0, 1.0, 1.0], + [6.4304748, 0.86830884, 1.0], + [-1.4413718, 0.9585318, 1.0], + [-4.279423, 0.9782264, 1.0], + [-0.973853, 0.9942283, 1.0], + [0.0, 1.0, 1.0], + [6.0025797, 0.86645776, 1.0], + [-1.0643132, 0.94587845, 1.0], + [-2.816222, 0.9981094, 1.0], + [-0.6120733, 0.999216, 1.0], + [0.0, 1.0, 1.0], + [5.2618656, 0.87934244, 1.0], + [-1.9260371, 0.92144936, 1.0], + [-3.44394, 0.9980872, 1.0], + [-0.8797513, 1.0006056, 1.0], + [0.0, 1.0, 1.0], + [4.48014, 0.9191667, 1.0], + [-2.3073015, 0.9339955, 1.0], + [-2.6852305, 0.9934067, 1.0], + [-0.5358713, 1.0003943, 1.0], + [0.0, 1.0, 1.0], + [3.1199076, 0.94316065, 1.0], + [-3.2203076, 0.9512757, 1.0], + [-3.2261748, 0.98376966, 1.0], + [-1.225657, 0.99541444, 1.0], + [0.0, 1.0, 1.0], + [1.5250582, 0.87108445, 1.0], + [-2.2838533, 0.8486961, 1.0], + [-3.0364053, 0.91204727, 1.0], + [-2.5156116, 0.9678634, 1.0], + [0.0, 1.0, 1.0], + [2.9791424, 0.90573484, 1.0], + [-1.1473076, 0.85067075, 1.0], + [-4.143441, 0.83307755, 1.0], + [-3.6135044, 0.8626736, 1.0], + [0.0, 1.0, 1.0], + [4.1917787, 0.8886335, 1.0], + [1.4209502, 0.84427315, 1.0], + [-1.902567, 0.8220766, 1.0], + [-1.2590855, 0.84276557, 1.0], + [0.0, 1.0, 1.0], + [6.5577917, 0.8582681, 1.0], + [4.414367, 0.8102154, 1.0], + [1.2046701, 0.8105167, 1.0], + [1.181831, 0.82471985, 1.0], + [0.0, 1.0, 1.0], + [5.644376, 0.800012, 1.0], + [4.4705477, 0.738722, 1.0], + [4.9720116, 0.78066784, 1.0], + [6.1397104, 0.79611427, 1.0], + [0.0, 1.0, 1.0], + [5.2449017, 0.72134954, 1.0], + [3.139061, 0.68135095, 1.0], + [3.8384428, 0.7519106, 1.0], + [6.912432, 0.7883944, 1.0], + [0.0, 1.0, 1.0], + [4.7535887, 0.67236185, 1.0], + [1.6767513, 0.6455759, 1.0], + [1.2658948, 0.7090765, 1.0], + [4.059322, 0.7603392, 1.0], + [0.0, 1.0, 1.0], + [4.5784483, 0.6933753, 1.0], + [0.27928707, 0.699313, 1.0], + [2.2699614, 0.6997288, 1.0], + [6.3981876, 0.672012, 1.0], + [0.0, 1.0, 1.0], + [3.7372062, 0.6813957, 1.0], + [1.5830798, 0.67802733, 1.0], + [3.873308, 0.6486227, 1.0], + [5.3511515, 0.6483506, 1.0], + [0.0, 1.0, 1.0], + [3.6191099, 0.6723053, 1.0], + [2.7363768, 0.64387995, 1.0], + [3.5159998, 0.6142523, 1.0], + [2.8553054, 0.64838594, 1.0], + [0.0, 1.0, 1.0], + [3.7020638, 0.6383956, 1.0], + [2.3508477, 0.6238232, 1.0], + [2.9867542, 0.61862445, 1.0], + [2.3013074, 0.64164305, 1.0], + [0.0, 1.0, 1.0], + [3.380534, 0.6982154, 1.0], + [1.8927245, 0.6699597, 1.0], + [2.130032, 0.6536506, 1.0], + [1.440734, 0.64402896, 1.0], + [0.0, 1.0, 1.0], + [3.307678, 0.74071306, 1.0], + [2.2724662, 0.70253104, 1.0], + [1.6237959, 0.6603189, 1.0], + [-0.29978958, 0.64183867, 1.0], + [0.0, 1.0, 1.0], + [2.4868233, 0.7586426, 1.0], + [1.4523857, 0.7366422, 1.0], + [-0.15140325, 0.68974066, 1.0], + [-1.4498984, 0.65608776, 1.0], + [0.0, 1.0, 1.0], + [1.5952954, 0.81705433, 1.0], + [0.3610198, 0.76384574, 1.0], + [-1.1764607, 0.7085227, 1.0], + [-2.0014498, 0.66375816, 1.0], + [0.0, 1.0, 1.0], + [0.009735043, 0.8393661, 1.0], + [-0.52455544, 0.74124515, 1.0], + [-0.48256284, 0.7240061, 1.0], + [-4.231129, 0.6864818, 1.0], + [0.0, 1.0, 1.0], + [-0.5011253, 0.85113287, 1.0], + [-1.526793, 0.741856, 1.0], + [-3.389466, 0.69727397, 1.0], + [-10.565374, 0.63681203, 1.0], + [0.0, 1.0, 1.0], + [-0.5787122, 0.8358186, 1.0], + [-1.0496063, 0.7413957, 1.0], + [-6.023933, 0.6728621, 1.0], + [-16.243042, 0.57179403, 1.0], + [0.0, 1.0, 1.0], + [-2.5238857, 0.81381685, 1.0], + [-3.973887, 0.73654264, 1.0], + [-13.029781, 0.5978037, 1.0], + [-18.08632, 0.5250716, 1.0], + [0.0, 1.0, 1.0], + [-2.3169994, 0.8272504, 1.0], + [-8.047612, 0.7040424, 1.0], + [-16.19256, 0.52720886, 1.0], + [-17.492706, 0.50534374, 1.0], + [0.0, 1.0, 1.0], + [-3.432385, 0.8549761, 1.0], + [-4.998627, 0.67764604, 1.0], + [-8.85871, 0.51909405, 1.0], + [-9.629815, 0.5025227, 1.0], + [0.0, 1.0, 1.0], + [-2.8054225, 0.83563244, 1.0], + [2.1752064, 0.66142064, 1.0], + [12.088789, 0.54614425, 1.0], + [12.621284, 0.51645017, 1.0], + [0.0, 1.0, 1.0], + [4.066309, 0.7654777, 1.0], + [9.837004, 0.6236221, 1.0], + [17.423119, 0.5883316, 1.0], + [21.496513, 0.5862999, 1.0], + [0.0, 1.0, 1.0], + [6.741904, 0.7127471, 1.0], + [9.628795, 0.64409775, 1.0], + [10.995573, 0.6744512, 1.0], + [6.354313, 0.84476554, 1.0], + [0.0, 1.0, 1.0], + [4.6841927, 0.7843752, 1.0], + [8.19482, 0.7160338, 1.0], + [6.0010133, 0.7742455, 1.0], + [0.18970187, 0.94752014, 1.0], + [0.0, 1.0, 1.0], + [7.842979, 0.8000173, 1.0], + [11.690232, 0.739449, 1.0], + [5.110884, 0.80710995, 1.0], + [-1.2012933, 0.922968, 1.0], + [0.0, 1.0, 1.0], + [3.3574314, 0.84719974, 1.0], + [5.8742213, 0.8123406, 1.0], + [2.516008, 0.8360613, 1.0], + [-0.37090135, 0.9494042, 1.0], + [0.0, 1.0, 1.0], + [3.4409087, 0.846134, 1.0], + [-0.13644595, 0.8567972, 1.0], + [-3.2113655, 0.839664, 1.0], + [-1.5690522, 0.91481286, 1.0], + [0.0, 1.0, 1.0], + [3.6356204, 0.8613304, 1.0], + [-1.9126496, 0.8373374, 1.0], + [-10.372357, 0.78524256, 1.0], + [-6.5644794, 0.844308, 1.0], + [0.0, 1.0, 1.0], + [0.35381076, 0.93528235, 1.0], + [-2.7392008, 0.89980006, 1.0], + [-9.026086, 0.7895136, 1.0], + [-4.9918127, 0.83979625, 1.0], + [0.0, 1.0, 1.0], + [-0.22767301, 0.91764885, 1.0], + [-0.28452978, 0.84251344, 1.0], + [-1.9016887, 0.84161645, 1.0], + [0.3422665, 0.93315935, 1.0], + [0.0, 1.0, 1.0], + [-0.123091474, 0.89455956, 1.0], + [-2.1685836, 0.84581566, 1.0], + [1.3732817, 0.84082806, 1.0], + [2.6831613, 0.92868334, 1.0], + [0.0, 1.0, 1.0], + [0.86503315, 0.88304895, 1.0], + [-1.5601242, 0.91238695, 1.0], + [0.37940353, 0.93464494, 1.0], + [1.0456579, 0.96896976, 1.0], + [0.0, 1.0, 1.0], + [2.7394576, 0.89377844, 1.0], + [-2.3233414, 0.9390823, 1.0], + [-5.721086, 0.8329246, 1.0], + [-3.1057966, 0.82862717, 1.0], + [0.0, 1.0, 1.0], + [1.6411211, 0.92481196, 1.0], + [-2.3975968, 0.85534614, 1.0], + [-5.8770084, 0.68478525, 1.0], + [-1.5260915, 0.6935012, 1.0], + [0.0, 1.0, 1.0], + [-0.2765417, 0.9208617, 1.0], + [-12.06742, 0.81020206, 1.0], + [-20.405844, 0.7374704, 1.0], + [-12.597571, 0.79101664, 1.0], + [0.0, 1.0, 1.0], + [-2.897616, 0.9211402, 1.0], + [-14.421744, 0.7903851, 1.0], + [-19.214266, 0.71761405, 1.0], + [-13.390762, 0.7809222, 1.0], + [0.0, 1.0, 1.0], + [-3.158559, 0.93960077, 1.0], + [-11.273947, 0.7577517, 1.0], + [-13.330117, 0.7017324, 1.0], + [-5.7236233, 0.8588474, 1.0], + [0.0, 1.0, 1.0], + [-1.592437, 0.9224862, 1.0], + [-1.9768095, 0.79358673, 1.0], + [-2.52034, 0.80400187, 1.0], + [-0.77087885, 0.96653724, 1.0], + [0.0, 1.0, 1.0], + [-4.4444714, 0.9292429, 1.0], + [1.6647115, 0.8260513, 1.0], + [1.4503638, 0.8003794, 1.0], + [-0.4073486, 0.9618924, 1.0], + [0.0, 1.0, 1.0], + [-2.7638762, 0.8422526, 1.0], + [-1.7696239, 0.8175861, 1.0], + [-2.5969453, 0.82940525, 1.0], + [-1.4243466, 0.9425862, 1.0], + [0.0, 1.0, 1.0], + [-5.8228145, 0.77634525, 1.0], + [-8.170251, 0.7666279, 1.0], + [-7.5123396, 0.81542164, 1.0], + [-1.8198384, 0.95125085, 1.0], + [0.0, 1.0, 1.0], + [-9.360387, 0.7797565, 1.0], + [-11.051248, 0.7752701, 1.0], + [-9.444043, 0.77841234, 1.0], + [-0.21361573, 0.87572235, 1.0], + [0.0, 1.0, 1.0], + [-13.2849245, 0.7415836, 1.0], + [-13.399887, 0.7924007, 1.0], + [4.650317, 0.81708753, 1.0], + [18.099548, 0.862965, 1.0], + [0.0, 1.0, 1.0], + [-14.249046, 0.7239625, 1.0], + [-12.644726, 0.787061, 1.0], + [1.3048109, 0.795617, 1.0], + [9.834041, 0.8405552, 1.0], + [0.0, 1.0, 1.0], + [-17.444523, 0.5949457, 1.0], + [-13.476753, 0.6840091, 1.0], + [-12.6381235, 0.6644669, 1.0], + [-9.62859, 0.6923138, 1.0], + [0.0, 1.0, 1.0], + [-14.0186615, 0.6051366, 1.0], + [-10.975076, 0.6890023, 1.0], + [-10.500401, 0.6949134, 1.0], + [-8.894962, 0.72246975, 1.0], + [0.0, 1.0, 1.0], + [-10.543245, 0.67233354, 1.0], + [-8.299539, 0.78261155, 1.0], + [-7.1005125, 0.845384, 1.0], + [-4.664283, 0.8898768, 1.0], + [0.0, 1.0, 1.0], + [-9.807613, 0.6154132, 1.0], + [-7.3155074, 0.76350015, 1.0], + [-5.8409595, 0.86448425, 1.0], + [-3.3455367, 0.93252707, 1.0], + [0.0, 1.0, 1.0], + [-7.5137596, 0.5462595, 1.0], + [-3.004627, 0.658936, 1.0], + [-1.8417222, 0.8381658, 1.0], + [-1.8682895, 0.9441728, 1.0], + [0.0, 1.0, 1.0], + [-2.9677281, 0.56995666, 1.0], + [0.90389174, 0.6623684, 1.0], + [1.5478073, 0.8449315, 1.0], + [0.2826305, 0.8891036, 1.0], + [0.0, 1.0, 1.0], + [0.82919896, 0.63599986, 1.0], + [2.8135967, 0.7249443, 1.0], + [7.3101892, 0.9042873, 1.0], + [7.7040653, 0.9522446, 1.0], + [0.0, 1.0, 1.0], + [2.789316, 0.6838728, 1.0], + [4.7111635, 0.81004953, 1.0], + [9.877694, 0.9593944, 1.0], + [10.302842, 0.9809844, 1.0], + [0.0, 1.0, 1.0], + [3.4230366, 0.7425048, 1.0], + [3.738975, 0.84671533, 1.0], + [10.307028, 0.958077, 1.0], + [10.699147, 0.9737339, 1.0], + [0.0, 1.0, 1.0], + [4.8469787, 0.7797298, 1.0], + [4.6221204, 0.8404099, 1.0], + [9.099866, 0.9094019, 1.0], + [8.047509, 0.95190895, 1.0], + [0.0, 1.0, 1.0], + [5.217233, 0.7993761, 1.0], + [1.3621497, 0.8585704, 1.0], + [4.016475, 0.86902857, 1.0], + [3.1859565, 0.94888914, 1.0], + [0.0, 1.0, 1.0], + [4.6767554, 0.8303384, 1.0], + [2.1546807, 0.8991955, 1.0], + [3.7274258, 0.9081848, 1.0], + [1.5931357, 0.9754997, 1.0], + [0.0, 1.0, 1.0], + [2.6186461, 0.8725928, 1.0], + [-0.23522311, 0.9146301, 1.0], + [0.9835199, 0.91176814, 1.0], + [0.58563864, 0.9759792, 1.0], + [0.0, 1.0, 1.0], + [2.1956127, 0.8745205, 1.0], + [-0.72214097, 0.8934399, 1.0], + [0.99975485, 0.9016286, 1.0], + [0.34649053, 0.98511404, 1.0], + [0.0, 1.0, 1.0], + [2.01588, 0.8797899, 1.0], + [-0.11028997, 0.90068436, 1.0], + [2.4051347, 0.8929491, 1.0], + [0.31044072, 0.98858964, 1.0], + [0.0, 1.0, 1.0], + [2.801372, 0.9000487, 1.0], + [3.2723014, 0.94898623, 1.0], + [3.251487, 0.95616883, 1.0], + [0.26165524, 0.9953507, 1.0], + [0.0, 1.0, 1.0], + [1.3384478, 0.94916874, 1.0], + [-0.76461834, 0.9320178, 1.0], + [-1.0340531, 0.937888, 1.0], + [-0.2052262, 0.9910225, 1.0], + [0.0, 1.0, 1.0], + [-1.5522646, 0.8218283, 1.0], + [-7.093138, 0.7325952, 1.0], + [-3.7177348, 0.77458364, 1.0], + [-0.5455225, 0.9524443, 1.0], + [0.0, 1.0, 1.0], + [-0.41480815, 0.85181725, 1.0], + [-0.36262518, 0.7285184, 1.0], + [1.2038608, 0.76166785, 1.0], + [0.34981248, 0.9373231, 1.0], + [0.0, 1.0, 1.0], + [3.0183747, 0.8615714, 1.0], + [6.522213, 0.7130658, 1.0], + [4.7299647, 0.7329898, 1.0], + [0.69027966, 0.9252049, 1.0], + [0.0, 1.0, 1.0], + [1.8928224, 0.86741346, 1.0], + [-0.22437978, 0.85102594, 1.0], + [-4.3790607, 0.8801903, 1.0], + [-1.6888652, 0.97201353, 1.0], + [0.0, 1.0, 1.0], + [1.9431077, 0.87125516, 1.0], + [-6.9137535, 0.94617844, 1.0], + [-11.575183, 0.9801642, 1.0], + [-2.7485921, 0.99310756, 1.0], + [0.0, 1.0, 1.0], + [0.68844545, 0.8476669, 1.0], + [-6.3806562, 0.9206668, 1.0], + [-11.188042, 0.98616713, 1.0], + [-1.8384333, 0.9988374, 1.0], + [0.0, 1.0, 1.0], + [-0.11813285, 0.83367205, 1.0], + [-5.729458, 0.87351876, 1.0], + [-9.243516, 0.94089884, 1.0], + [-1.174662, 0.994138, 1.0], + [0.0, 1.0, 1.0], + [-0.088543095, 0.81548285, 1.0], + [-2.1764681, 0.8262967, 1.0], + [-2.4391656, 0.9571753, 1.0], + [-0.27592131, 0.99790347, 1.0], + [0.0, 1.0, 1.0], + [0.30223542, 0.8490914, 1.0], + [-2.565041, 0.84209675, 1.0], + [-0.73276377, 0.9614384, 1.0], + [-0.06476155, 0.9990294, 1.0], + [0.0, 1.0, 1.0], + [0.5150307, 0.87056994, 1.0], + [-2.4532168, 0.83294433, 1.0], + [-1.4075577, 0.94370353, 1.0], + [-0.6351769, 0.9955674, 1.0], + [0.0, 1.0, 1.0], + [6.843285, 1.0033246, 1.0], + [7.4428577, 0.95056987, 1.0], + [2.108944, 0.94717664, 1.0], + [-0.25893068, 0.99069285, 1.0], + [0.0, 1.0, 1.0], + [9.999977, 1.0822781, 1.0], + [12.696831, 1.0251509, 1.0], + [5.93438, 0.93318224, 1.0], + [0.086959586, 0.9213895, 1.0], + [0.0, 1.0, 1.0], + [11.269931, 1.041934, 1.0], + [13.652761, 1.0116991, 1.0], + [7.9889326, 0.8788855, 1.0], + [6.5503616, 0.8362481, 1.0], + [0.0, 1.0, 1.0], + [9.251398, 0.93022597, 1.0], + [9.773671, 0.9091432, 1.0], + [11.005983, 0.7908927, 1.0], + [14.940665, 0.7742994, 1.0], + [0.0, 1.0, 1.0], + [6.4155545, 0.85385036, 1.0], + [8.46417, 0.829654, 1.0], + [12.680234, 0.7829167, 1.0], + [16.267979, 0.77082276, 1.0], + [0.0, 1.0, 1.0], + [4.912974, 0.8363341, 1.0], + [7.421986, 0.7468915, 1.0], + [13.157313, 0.70293534, 1.0], + [15.670254, 0.72895324, 1.0], + [0.0, 1.0, 1.0], + [2.2732983, 0.83324426, 1.0], + [4.6879416, 0.76169664, 1.0], + [10.194166, 0.70281017, 1.0], + [15.137923, 0.6564709, 1.0], + [0.0, 1.0, 1.0], + [0.691554, 0.8958847, 1.0], + [2.522616, 0.835281, 1.0], + [9.138479, 0.64440495, 1.0], + [13.552704, 0.5891457, 1.0], + [0.0, 1.0, 1.0], + [1.0758106, 0.8807258, 1.0], + [3.3481083, 0.76597595, 1.0], + [9.720517, 0.59901714, 1.0], + [11.450588, 0.57016766, 1.0], + [0.0, 1.0, 1.0], + [1.6788739, 0.8362954, 1.0], + [4.2696314, 0.6845474, 1.0], + [6.650018, 0.60105395, 1.0], + [7.2880855, 0.60911906, 1.0], + [0.0, 1.0, 1.0], + [1.7132614, 0.8527012, 1.0], + [4.419341, 0.72993284, 1.0], + [5.6017246, 0.65223414, 1.0], + [5.9043407, 0.6311042, 1.0], + [0.0, 1.0, 1.0], + [0.8517584, 0.93619037, 1.0], + [3.993357, 0.8524601, 1.0], + [4.7696304, 0.73141557, 1.0], + [5.134474, 0.6413797, 1.0], + [0.0, 1.0, 1.0], + [0.8878054, 0.9270773, 1.0], + [3.787974, 0.889603, 1.0], + [2.4917884, 0.7853351, 1.0], + [-1.3098156, 0.6682702, 1.0], + [0.0, 1.0, 1.0], + [1.0253179, 0.92877746, 1.0], + [4.033826, 0.9008868, 1.0], + [2.3986337, 0.83885485, 1.0], + [-2.687886, 0.695036, 1.0], + [0.0, 1.0, 1.0], + [0.32751077, 0.94140244, 1.0], + [2.0182226, 0.85748637, 1.0], + [-1.1619195, 0.7632388, 1.0], + [-5.5223203, 0.6635563, 1.0], + [0.0, 1.0, 1.0], + [-0.050555307, 0.9604787, 1.0], + [0.5055935, 0.83031297, 1.0], + [-1.6055543, 0.70797473, 1.0], + [-8.740934, 0.6385445, 1.0], + [0.0, 1.0, 1.0], + [-0.79567736, 0.96682066, 1.0], + [-3.8943377, 0.8166324, 1.0], + [-5.3518414, 0.68081903, 1.0], + [-8.843345, 0.6458594, 1.0], + [0.0, 1.0, 1.0], + [-1.6558982, 0.9616084, 1.0], + [-5.2786994, 0.80397224, 1.0], + [-10.992683, 0.63992244, 1.0], + [-16.177847, 0.58813363, 1.0], + [0.0, 1.0, 1.0], + [-1.75773, 0.9437587, 1.0], + [-6.6879635, 0.7789285, 1.0], + [-14.333078, 0.6058587, 1.0], + [-18.033953, 0.5445878, 1.0], + [0.0, 1.0, 1.0], + [0.043338794, 0.9552455, 1.0], + [-5.321281, 0.83083135, 1.0], + [-16.969313, 0.5537831, 1.0], + [-19.017706, 0.51461524, 1.0], + [0.0, 1.0, 1.0], + [-1.0175552, 0.9202445, 1.0], + [-2.6896808, 0.7869496, 1.0], + [-9.4466, 0.5490753, 1.0], + [-10.706868, 0.5131165, 1.0], + [0.0, 1.0, 1.0], + [-2.088289, 0.8988069, 1.0], + [-0.30899394, 0.75239635, 1.0], + [9.381413, 0.59920007, 1.0], + [12.207738, 0.5500292, 1.0], + [0.0, 1.0, 1.0], + [0.4638748, 0.87054735, 1.0], + [5.293201, 0.7064153, 1.0], + [13.263786, 0.6700958, 1.0], + [17.00946, 0.66093135, 1.0], + [0.0, 1.0, 1.0], + [1.8831717, 0.8199807, 1.0], + [4.7780185, 0.6955879, 1.0], + [1.744097, 0.76860195, 1.0], + [1.6281474, 0.8714117, 1.0], + [0.0, 1.0, 1.0], + [-0.23940727, 0.86877394, 1.0], + [-3.3042376, 0.77865523, 1.0], + [-12.2173395, 0.7857939, 1.0], + [-8.226082, 0.87283903, 1.0], + [0.0, 1.0, 1.0], + [4.590711, 0.8670613, 1.0], + [6.093016, 0.75086886, 1.0], + [-8.317774, 0.7136495, 1.0], + [-12.525191, 0.7473165, 1.0], + [0.0, 1.0, 1.0], + [1.1493275, 0.8251384, 1.0], + [1.9776939, 0.76206553, 1.0], + [-6.1686516, 0.72631574, 1.0], + [-6.670901, 0.8287188, 1.0], + [0.0, 1.0, 1.0], + [0.16302122, 0.8325588, 1.0], + [-2.9226806, 0.84611636, 1.0], + [-4.327261, 0.8001389, 1.0], + [-1.8962665, 0.88277936, 1.0], + [0.0, 1.0, 1.0], + [1.857538, 0.87345517, 1.0], + [-4.3408766, 0.82445586, 1.0], + [-10.225279, 0.7459497, 1.0], + [-7.025876, 0.81165254, 1.0], + [0.0, 1.0, 1.0], + [0.5294373, 0.88929296, 1.0], + [-2.200273, 0.8323351, 1.0], + [-10.405199, 0.74956995, 1.0], + [-6.132038, 0.81040746, 1.0], + [0.0, 1.0, 1.0], + [-0.16174325, 0.96300906, 1.0], + [-2.8326685, 0.88233006, 1.0], + [-1.4370927, 0.8078291, 1.0], + [2.6871767, 0.8748735, 1.0], + [0.0, 1.0, 1.0], + [-1.3610977, 0.96746254, 1.0], + [-3.2795222, 0.9109032, 1.0], + [-0.09399727, 0.89467824, 1.0], + [1.9920977, 0.94535923, 1.0], + [0.0, 1.0, 1.0], + [0.7663058, 0.9140197, 1.0], + [-1.8021601, 0.91958934, 1.0], + [0.29760635, 0.93971634, 1.0], + [0.9959274, 0.97391975, 1.0], + [0.0, 1.0, 1.0], + [2.160797, 0.8958013, 1.0], + [1.4534999, 0.89726895, 1.0], + [0.68010294, 0.89640915, 1.0], + [-0.5888536, 0.93606305, 1.0], + [0.0, 1.0, 1.0], + [-1.0493987, 0.968603, 1.0], + [0.9824815, 0.89990664, 1.0], + [-0.49817523, 0.7474924, 1.0], + [-1.0627013, 0.79141307, 1.0], + [0.0, 1.0, 1.0], + [-1.48714, 0.9750619, 1.0], + [-2.067653, 0.8603522, 1.0], + [-8.571636, 0.7305363, 1.0], + [-4.7118134, 0.8108301, 1.0], + [0.0, 1.0, 1.0], + [-1.725708, 0.98409593, 1.0], + [-7.151362, 0.8263027, 1.0], + [-12.0670395, 0.7186623, 1.0], + [-6.794398, 0.8430157, 1.0], + [0.0, 1.0, 1.0], + [-3.816568, 0.9682442, 1.0], + [-7.979974, 0.7548534, 1.0], + [-12.051381, 0.6975676, 1.0], + [-6.639557, 0.8519053, 1.0], + [0.0, 1.0, 1.0], + [-1.947905, 0.92271626, 1.0], + [-11.209933, 0.7695706, 1.0], + [-13.407172, 0.7412468, 1.0], + [-4.071555, 0.91521406, 1.0], + [0.0, 1.0, 1.0], + [-1.0821028, 0.9144603, 1.0], + [-4.187682, 0.8065792, 1.0], + [-5.4081926, 0.75121087, 1.0], + [-2.8812873, 0.896328, 1.0], + [0.0, 1.0, 1.0], + [-0.7102738, 0.89479977, 1.0], + [1.0865877, 0.75759023, 1.0], + [-2.4494224, 0.68042827, 1.0], + [-4.5266833, 0.78840774, 1.0], + [0.0, 1.0, 1.0], + [-0.7601203, 0.91100687, 1.0], + [-0.37138972, 0.77027845, 1.0], + [-1.2509688, 0.72588146, 1.0], + [-1.5607324, 0.8591345, 1.0], + [0.0, 1.0, 1.0], + [-0.8733535, 0.9064345, 1.0], + [-3.315706, 0.7831079, 1.0], + [1.3590022, 0.72675896, 1.0], + [6.3603826, 0.8151842, 1.0], + [0.0, 1.0, 1.0], + [1.1287688, 0.9338128, 1.0], + [1.2533982, 0.73052305, 1.0], + [13.717384, 0.74975586, 1.0], + [18.239985, 0.8096522, 1.0], + [0.0, 1.0, 1.0], + [-3.185362, 0.8746891, 1.0], + [-5.5458236, 0.6902374, 1.0], + [2.3753786, 0.7056687, 1.0], + [7.2279525, 0.7721612, 1.0], + [0.0, 1.0, 1.0], + [-5.603719, 0.7863287, 1.0], + [-11.014495, 0.64638335, 1.0], + [-13.238409, 0.6131685, 1.0], + [-11.532672, 0.64018786, 1.0], + [0.0, 1.0, 1.0], + [-9.553351, 0.64817256, 1.0], + [-10.655705, 0.5973265, 1.0], + [-11.155985, 0.6075254, 1.0], + [-8.812423, 0.6737688, 1.0], + [0.0, 1.0, 1.0], + [-12.175202, 0.59095675, 1.0], + [-12.133568, 0.60512704, 1.0], + [-9.002674, 0.67072225, 1.0], + [-3.0383387, 0.8035592, 1.0], + [0.0, 1.0, 1.0], + [-12.228456, 0.5612792, 1.0], + [-11.996711, 0.59280115, 1.0], + [-9.705961, 0.687603, 1.0], + [-7.773533, 0.80342376, 1.0], + [0.0, 1.0, 1.0], + [-8.663709, 0.5645525, 1.0], + [-7.764659, 0.6173788, 1.0], + [-10.524891, 0.75197405, 1.0], + [-11.214434, 0.77335423, 1.0], + [0.0, 1.0, 1.0], + [-5.396718, 0.67858976, 1.0], + [-7.135087, 0.71634585, 1.0], + [-2.9309926, 0.74045223, 1.0], + [-4.4241138, 0.7625005, 1.0], + [0.0, 1.0, 1.0], + [-7.4634604, 0.93015254, 1.0], + [-11.637723, 0.8254694, 1.0], + [1.3877314, 0.75356996, 1.0], + [7.4537945, 0.7911049, 1.0], + [0.0, 1.0, 1.0], + [-2.0797722, 0.8257694, 1.0], + [-9.952251, 0.7275845, 1.0], + [-1.22222, 0.76648885, 1.0], + [8.443583, 0.86626667, 1.0], + [0.0, 1.0, 1.0], + [-2.719869, 0.81726277, 1.0], + [-8.175672, 0.6951251, 1.0], + [-5.122706, 0.7475115, 1.0], + [2.8121452, 0.87993133, 1.0], + [0.0, 1.0, 1.0], + [-0.5990696, 0.8328561, 1.0], + [-0.61761487, 0.68436635, 1.0], + [-1.5171839, 0.73146296, 1.0], + [-1.249486, 0.9136568, 1.0], + [0.0, 1.0, 1.0], + [-1.1546803, 0.9241708, 1.0], + [-4.1920066, 0.75697076, 1.0], + [-4.5107627, 0.73179376, 1.0], + [-2.238509, 0.89131826, 1.0], + [0.0, 1.0, 1.0], + [0.956483, 0.96588635, 1.0], + [-1.0265821, 0.84155124, 1.0], + [-2.66922, 0.8079714, 1.0], + [-2.6284955, 0.95289963, 1.0], + [0.0, 1.0, 1.0], + [-2.347914, 0.93753785, 1.0], + [-1.3445268, 0.83233905, 1.0], + [-4.9731174, 0.79405123, 1.0], + [-4.1485014, 0.9415318, 1.0], + [0.0, 1.0, 1.0], + [-5.317987, 0.923656, 1.0], + [-4.806357, 0.8041632, 1.0], + [-4.5212965, 0.7689882, 1.0], + [-1.453726, 0.9560485, 1.0], + [0.0, 1.0, 1.0], + [-9.224498, 0.98964065, 1.0], + [-10.262257, 0.8849552, 1.0], + [-2.7684932, 0.7562575, 1.0], + [-0.23699033, 0.9539201, 1.0], + [0.0, 1.0, 1.0], + [-3.002255, 0.97370094, 1.0], + [-2.716253, 0.914175, 1.0], + [1.5104983, 0.89325345, 1.0], + [0.14295425, 0.99063605, 1.0], + [0.0, 1.0, 1.0], + [0.36685634, 0.9282719, 1.0], + [-8.506473, 0.8523475, 1.0], + [-12.094891, 0.90356064, 1.0], + [-2.1393285, 0.9895608, 1.0], + [0.0, 1.0, 1.0], + [-9.882091, 0.7290178, 1.0], + [-9.057711, 0.6043994, 1.0], + [-4.1615314, 0.62489474, 1.0], + [-1.1871183, 0.8577925, 1.0], + [0.0, 1.0, 1.0], + [-4.1569786, 0.7451061, 1.0], + [0.43200502, 0.61813676, 1.0], + [5.076925, 0.62860876, 1.0], + [2.997295, 0.8324412, 1.0], + [0.0, 1.0, 1.0], + [4.9129972, 0.81619036, 1.0], + [7.9838896, 0.63521093, 1.0], + [8.996219, 0.6237475, 1.0], + [4.1178374, 0.81866705, 1.0], + [0.0, 1.0, 1.0], + [4.801817, 0.8912142, 1.0], + [8.943227, 0.74254304, 1.0], + [5.654569, 0.76142734, 1.0], + [0.74153537, 0.9477238, 1.0], + [0.0, 1.0, 1.0], + [4.679993, 0.9053932, 1.0], + [0.72251046, 0.8655048, 1.0], + [-8.141989, 0.94272244, 1.0], + [-2.3421266, 1.0002514, 1.0], + [0.0, 1.0, 1.0], + [7.750666, 0.8927274, 1.0], + [1.6069077, 0.8618249, 1.0], + [-12.324378, 0.9456767, 1.0], + [-3.3410037, 0.99778306, 1.0], + [0.0, 1.0, 1.0], + [4.629134, 0.8679321, 1.0], + [-5.62241, 0.8793257, 1.0], + [-17.850567, 0.9430222, 1.0], + [-4.631688, 0.9899626, 1.0], + [0.0, 1.0, 1.0], + [6.1155505, 0.7920305, 1.0], + [6.8210797, 0.7819359, 1.0], + [-3.9575536, 0.92559654, 1.0], + [-0.8186751, 0.9944719, 1.0], + [0.0, 1.0, 1.0], + [6.673559, 0.9143873, 1.0], + [8.677403, 0.9072902, 1.0], + [3.8159382, 0.9608776, 1.0], + [0.21488708, 0.9980647, 1.0], + [0.0, 1.0, 1.0], + [9.044622, 0.9573329, 1.0], + [11.07611, 0.8985059, 1.0], + [3.8399148, 0.9358206, 1.0], + [0.10755205, 0.997731, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-tone/sony-dro-off.json b/assets/camera-tone/sony-dro-off.json new file mode 100644 index 000000000..46397044f --- /dev/null +++ b/assets/camera-tone/sony-dro-off.json @@ -0,0 +1,52 @@ +{ + "description": "Sony's global tone curve with the Dynamic Range Optimizer (DRO) off, Standard creative style: knots of scene luminance (white-balanced camera RGB with baseline exposure, through the colour model) to display-linear luminance. The camera look of an ARW shot with DRO on is lowered to it wherever it is darker (crates/engine/src/camera_preview.rs, docs/camera-preview-colour.md, issue #244).", + "derivation": "Each source raw was paired with its own embedded camera JPEG at the 96-pixel proxy of the per-photo fit (camera_preview::collect_pairs). The pairs of all 12 files were pooled as one model, as lightcraft-cli calibrate pools a camera profile: a colour matrix and hue/saturation table were fitted to the pool, and the tone curve was quantile-matched (camera_preview::fit_tone) between the pooled scene luminances through that colour model and the JPEG luminances. Knots rounded to 5 decimals. Aggregate statistics only, no image content.", + "sources": [ + { "body": "ILCA-77M2", "url": "https://raw.pixls.us/data/Sony/ILCA-77M2/_DSC0244.ARW", "sha256": "f41ceff680b675460cc1fe258a24d8c8c01c52a300933f3ab6401d9a20b60dcc", "licence": "CC0" }, + { "body": "ILCE-3500", "url": "https://raw.pixls.us/data/Sony/ILCE-3500/DSC06923.ARW", "sha256": "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8", "licence": "CC0" }, + { "body": "ILCE-5000", "url": "https://raw.pixls.us/data/Sony/ILCE-5000/_DSC0793.ARW", "sha256": "a919c8535eed68577f1745fb9674ccf333962628ccdec6d48a6f635208467f6c", "licence": "CC0" }, + { "body": "ILCE-6600", "url": "https://raw.pixls.us/data/Sony/ILCE-6600/_DSC0268.ARW", "sha256": "041d71d70148ca77164f19d061e3582adb34399ea5a4baba32960f2956fb0362", "licence": "CC0" }, + { "body": "ILCE-7CR", "url": "https://raw.pixls.us/data/Sony/ILCE-7CR/DSC00798.ARW", "sha256": "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5", "licence": "CC0" }, + { "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/UNCOMPRESSED_14bit.ARW", "sha256": "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd", "licence": "CC0" }, + { "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0009.ARW", "sha256": "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f", "licence": "CC0" }, + { "body": "ILCE-7M3", "url": "https://raw.pixls.us/data/Sony/ILCE-7M3/_DSC0010.ARW", "sha256": "3ae2ff75cfa7240d0b4458f662afd28665c5fd1e83d230ac73f5ad69d3df8592", "licence": "CC0" }, + { "body": "ILCE-7S", "url": "https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW", "sha256": "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2", "licence": "CC0" }, + { "body": "ILCE-9M2", "url": "https://raw.pixls.us/data/Sony/ILCE-9M2/SONY_A9II_(ILCE-9M2)_-_compressed_(3:2)_14bit.ARW", "sha256": "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97", "licence": "CC0" }, + { "body": "NEX-F3", "url": "https://raw.pixls.us/data/Sony/NEX-F3/_DSC4017.ARW", "sha256": "cfdc60d118b5ce2dce10d459dcb98ad63c6db3e609999932ca8964d86daf836b", "licence": "CC0" }, + { "body": "SLT-A57", "url": "https://raw.pixls.us/data/Sony/SLT-A57/a57-06125.arw", "sha256": "0e9064f369a3947f892c1d7b1971f75d3879f5673134932d5cc7a67bf4263ba4", "licence": "CC0" } + ], + "knots": [ + [0.01655, 0.01799], + [0.01970, 0.02376], + [0.02212, 0.03008], + [0.02469, 0.03634], + [0.02737, 0.04320], + [0.03049, 0.05044], + [0.03425, 0.05812], + [0.03824, 0.06667], + [0.04232, 0.07817], + [0.04658, 0.09059], + [0.05095, 0.10268], + [0.05550, 0.11731], + [0.05962, 0.13173], + [0.06409, 0.14529], + [0.06843, 0.15939], + [0.07314, 0.17363], + [0.07800, 0.18897], + [0.08298, 0.20502], + [0.08909, 0.22159], + [0.09693, 0.23964], + [0.10540, 0.26082], + [0.11579, 0.28882], + [0.12763, 0.31986], + [0.14079, 0.35198], + [0.15786, 0.39293], + [0.17949, 0.44101], + [0.20330, 0.49407], + [0.23466, 0.54244], + [0.27207, 0.60078], + [0.31673, 0.66533], + [0.38682, 0.74143], + [0.52622, 0.80307] + ] +} diff --git a/contributors/contributors.json b/contributors/contributors.json index b41b08ebf..1072607cb 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,23 @@ "schema": 1, "repo": "storytold/lightcraft", "branch": "main", - "head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb", - "head_date": "2026-10-08T00:53:19Z", - "commits": 424, + "head": "88ea1a804b35709e1fe436e3792adf0428b97daa", + "head_date": "2026-10-09T13:05:04Z", + "commits": 729, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 71, - "commits": 330, - "lines_added": 125623, - "lines_deleted": 8643, + "prs": 83, + "commits": 348, + "lines_added": 127920, + "lines_deleted": 8745, "binary_added": 38, "binary_deleted": 8, "first_commit": "2026-09-30T14:04:12Z", - "last_commit": "2026-10-08T00:32:34Z" + "last_commit": "2026-10-09T13:05:04Z" }, { "login": "leipsfur", @@ -50,40 +50,40 @@ "login": "storybored379", "people_entry": false, "github_name": null, - "prs": 7, - "commits": 7, - "lines_added": 1194, - "lines_deleted": 82, + "prs": 8, + "commits": 9, + "lines_added": 1925, + "lines_deleted": 119, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-04T00:28:49Z", - "last_commit": "2026-10-06T06:51:43Z" + "last_commit": "2026-10-09T06:02:21Z" }, { "login": "midasdf", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 424, - "lines_deleted": 25, + "prs": 5, + "commits": 5, + "lines_added": 755, + "lines_deleted": 80, "binary_added": 1, "binary_deleted": 0, "first_commit": "2026-10-05T16:07:26Z", - "last_commit": "2026-10-05T16:07:26Z" + "last_commit": "2026-10-09T06:04:40Z" }, { "login": "voshart", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 12, - "lines_added": 2481, - "lines_deleted": 1389, + "prs": 8, + "commits": 26, + "lines_added": 30524, + "lines_deleted": 4416, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-05T16:23:27Z", - "last_commit": "2026-10-07T16:07:24Z" + "last_commit": "2026-10-09T02:31:04Z" }, { "login": "arata-1972", @@ -124,6 +124,19 @@ "first_commit": "2026-10-06T20:21:14Z", "last_commit": "2026-10-06T20:21:14Z" }, + { + "login": "Exalted5373", + "people_entry": false, + "github_name": null, + "prs": 5, + "commits": 11, + "lines_added": 1988, + "lines_deleted": 132, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-06T21:01:23Z", + "last_commit": "2026-10-09T01:33:43Z" + }, { "login": "treyreynolds", "people_entry": false, @@ -193,7 +206,7 @@ "login": "itsmunzir", "people_entry": false, "github_name": null, - "prs": 1, + "prs": 2, "commits": 1, "lines_added": 46, "lines_deleted": 30, @@ -245,40 +258,40 @@ "login": "radialmonster", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 19, - "lines_added": 2630, - "lines_deleted": 259, + "prs": 26, + "commits": 53, + "lines_added": 7227, + "lines_deleted": 717, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T12:33:51Z", - "last_commit": "2026-10-08T00:46:10Z" + "last_commit": "2026-10-09T11:31:46Z" }, { "login": "bogusyogi", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 434, - "lines_deleted": 29, + "prs": 2, + "commits": 17, + "lines_added": 5124, + "lines_deleted": 603, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T17:06:00Z", - "last_commit": "2026-10-07T17:06:00Z" + "last_commit": "2026-10-08T19:26:28Z" }, { "login": "nledford", "people_entry": false, "github_name": null, - "prs": 4, - "commits": 8, - "lines_added": 525, - "lines_deleted": 60, + "prs": 10, + "commits": 88, + "lines_added": 9299, + "lines_deleted": 1024, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T17:33:38Z", - "last_commit": "2026-10-07T22:49:00Z" + "last_commit": "2026-10-09T00:59:12Z" }, { "login": "MusseJusse", @@ -336,14 +349,27 @@ "login": "DWAA1660", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 54, - "lines_deleted": 12, + "prs": 2, + "commits": 2, + "lines_added": 206, + "lines_deleted": 42, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T22:08:01Z", - "last_commit": "2026-10-07T22:08:01Z" + "last_commit": "2026-10-08T17:04:19Z" + }, + { + "login": "sebastianpfluegelmeier", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 4, + "lines_added": 23063, + "lines_deleted": 17968, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-07T22:14:20Z", + "last_commit": "2026-10-08T14:38:56Z" }, { "login": "agorevski", @@ -371,6 +397,344 @@ "first_commit": "2026-10-08T00:53:19Z", "last_commit": "2026-10-08T00:53:19Z" }, + { + "login": "Hanmo123", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 6, + "lines_added": 922, + "lines_deleted": 84, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T02:16:44Z", + "last_commit": "2026-10-09T05:35:02Z" + }, + { + "login": "wondarkus", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 8, + "lines_added": 2776, + "lines_deleted": 1387, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T02:56:08Z", + "last_commit": "2026-10-08T04:23:30Z" + }, + { + "login": "xak47d", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 58, + "lines_deleted": 10, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T04:10:21Z", + "last_commit": "2026-10-08T04:10:21Z" + }, + { + "login": "marceldenzer", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 2864, + "lines_deleted": 261, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T05:27:32Z", + "last_commit": "2026-10-08T05:27:32Z" + }, + { + "login": "bflatastic", + "people_entry": false, + "github_name": null, + "prs": 25, + "commits": 2, + "lines_added": 108, + "lines_deleted": 24, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T06:30:19Z", + "last_commit": "2026-10-08T14:00:00Z" + }, + { + "login": "wstock", + "people_entry": false, + "github_name": null, + "prs": 8, + "commits": 8, + "lines_added": 834, + "lines_deleted": 67, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T07:45:39Z", + "last_commit": "2026-10-08T11:55:57Z" + }, + { + "login": "marek-hubar", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 212, + "lines_deleted": 29, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T09:01:44Z", + "last_commit": "2026-10-08T09:01:44Z" + }, + { + "login": "switch87", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 762, + "lines_deleted": 34, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T11:00:29Z", + "last_commit": "2026-10-08T11:00:29Z" + }, + { + "login": "h-a-n-a-s-h-i", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 2, + "lines_added": 50, + "lines_deleted": 50, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:12:53Z", + "last_commit": "2026-10-08T14:01:45Z" + }, + { + "login": "Issuenate", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 107, + "lines_deleted": 12, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T12:26:27Z", + "last_commit": "2026-10-08T12:28:18Z" + }, + { + "login": "XusBadia", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 231, + "lines_deleted": 1, + "binary_added": 2, + "binary_deleted": 0, + "first_commit": "2026-10-08T13:12:32Z", + "last_commit": "2026-10-08T13:12:32Z" + }, + { + "login": "MarekSpodBiedry", + "people_entry": false, + "github_name": null, + "prs": 5, + "commits": 6, + "lines_added": 336, + "lines_deleted": 29, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T13:44:47Z", + "last_commit": "2026-10-09T08:45:45Z" + }, + { + "login": "peiyan0", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 215, + "lines_deleted": 80, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T15:36:56Z", + "last_commit": "2026-10-09T08:09:23Z" + }, + { + "login": "hemna", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 459, + "lines_deleted": 5, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T16:45:33Z", + "last_commit": "2026-10-08T16:45:33Z" + }, + { + "login": "joshuaswarren", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 349, + "lines_deleted": 18, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T16:47:59Z", + "last_commit": "2026-10-08T16:47:59Z" + }, + { + "login": "wrgdbtry", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 2733, + "lines_deleted": 63, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T19:22:13Z", + "last_commit": "2026-10-08T19:22:13Z" + }, + { + "login": "parkerdotexe", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 1, + "lines_added": 712, + "lines_deleted": 84, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T19:54:50Z", + "last_commit": "2026-10-08T19:54:50Z" + }, + { + "login": "ragtux", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 805, + "lines_deleted": 50, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T22:50:18Z", + "last_commit": "2026-10-08T23:42:19Z" + }, + { + "login": "sanyamsingh001", + "people_entry": true, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 431, + "lines_deleted": 19, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T03:19:47Z", + "last_commit": "2026-10-09T03:19:47Z" + }, + { + "login": "onemannoplan", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 1, + "lines_deleted": 2, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T04:53:21Z", + "last_commit": "2026-10-09T04:53:21Z" + }, + { + "login": "Yijie-Si", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 1, + "lines_added": 267, + "lines_deleted": 43, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T06:48:00Z", + "last_commit": "2026-10-09T06:48:00Z" + }, + { + "login": "HarrisXiu", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 69, + "lines_deleted": 1, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T09:34:51Z", + "last_commit": "2026-10-09T09:34:51Z" + }, + { + "login": "rock-meister", + "people_entry": true, + "github_name": "rock-meister", + "prs": 3, + "commits": 4, + "lines_added": 263, + "lines_deleted": 44, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T10:15:52Z", + "last_commit": "2026-10-09T11:26:47Z" + }, + { + "login": "QuirkyTurtle94", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 153, + "lines_deleted": 39, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T11:08:11Z", + "last_commit": "2026-10-09T11:08:11Z" + }, + { + "login": "BuonBreda", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null + }, + { + "login": "cookingwithsimon", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null + }, { "login": "GuintherKovalski", "people_entry": false, @@ -384,6 +748,32 @@ "first_commit": null, "last_commit": null }, + { + "login": "henu043", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null + }, + { + "login": "stano-bustor", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null + }, { "login": "XRTce", "people_entry": false, @@ -404,36 +794,45 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 364, - "lines_added": 131810, - "lines_deleted": 9367 + "commits": 475, + "lines_added": 147839, + "lines_deleted": 10702 }, { "id": "anthropic/claude-sonnet/5.5", "company": "Anthropic", "model": "Claude Sonnet", "version": "5.5", - "commits": 31, - "lines_added": 3941, - "lines_deleted": 1543 - }, - { - "id": "openai/codex", - "company": "OpenAI", - "model": "Codex", - "version": "", - "commits": 14, - "lines_added": 8341, - "lines_deleted": 995 + "commits": 116, + "lines_added": 15071, + "lines_deleted": 2624 }, { "id": "anthropic/claude", "company": "Anthropic", "model": "Claude", "version": "", - "commits": 2, - "lines_added": 1304, - "lines_deleted": 192 + "commits": 65, + "lines_added": 8385, + "lines_deleted": 952 + }, + { + "id": "openai/codex", + "company": "OpenAI", + "model": "Codex", + "version": "", + "commits": 33, + "lines_added": 14418, + "lines_deleted": 1647 + }, + { + "id": "anthropic/claude-fable/5.1", + "company": "Anthropic", + "model": "Claude Fable", + "version": "5.1", + "commits": 9, + "lines_added": 1548, + "lines_deleted": 112 }, { "id": "anthropic/claude-sonnet/5", @@ -453,6 +852,15 @@ "lines_added": 1633, "lines_deleted": 3 }, + { + "id": "deepseek/deepseek-flash/v4", + "company": "DeepSeek", + "model": "DeepSeek Flash", + "version": "V4", + "commits": 1, + "lines_added": 1439, + "lines_deleted": 6 + }, { "id": "deepseek/deepseek/v3.2", "company": "DeepSeek", @@ -461,6 +869,15 @@ "commits": 1, "lines_added": 1895, "lines_deleted": 294 + }, + { + "id": "openai/gpt/6", + "company": "OpenAI", + "model": "GPT", + "version": "6", + "commits": 1, + "lines_added": 2864, + "lines_deleted": 261 } ] } diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index c70c2831c..604fb0397 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -19,4 +19,5 @@ log = { workspace = true } web-time = { workspace = true } [dev-dependencies] +lightcraft-geom = { workspace = true } proptest = { workspace = true } diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index 06e8e0f1a..d180d013d 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -40,11 +40,17 @@ pub fn weekday(iso: &str) -> Option<&'static str> { } /// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was. -/// A date without a time gets one. `None` if it doesn't parse. +/// A date without a time gets one. `None` if it doesn't parse, or if the result leaves years +/// 0000–9999 (an ISO date here has four year digits, so it couldn't be read back); `secs` may be +/// anything, it never overflows. pub fn shift_iso(iso: &str, secs: i64) -> Option { - let t = iso_seconds(iso)?; + let t = iso_seconds(iso)?.checked_add(secs)?; + let (first, last) = (iso_seconds("0000-01-01T00:00:00")?, iso_seconds("9999-12-31T23:59:59")?); + if !(first..=last).contains(&t) { + return None; + } let tail = iso.trim().get(19..).unwrap_or(""); - Some(format!("{}{tail}", civil(t + secs))) + Some(format!("{}{tail}", civil(t))) } /// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing @@ -204,6 +210,14 @@ mod tests { assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00")); assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00")); assert_eq!(shift_iso("nope", 5), None); + // hostile shifts never overflow, and a result outside years 0000–9999 (which no ISO date + // here can read back) is refused rather than stored + for secs in [i64::MAX, i64::MIN, i64::MAX / 2, -(i64::MAX / 2)] { + assert_eq!(shift_iso("2026-01-01T00:00:00", secs), None, "{secs}"); + } + assert_eq!(shift_iso("9999-12-31T23:59:59", 1), None); + assert_eq!(shift_iso("0000-01-01T00:00:00", -1), None); + assert_eq!(shift_iso("9999-12-31T23:59:58", 1).as_deref(), Some("9999-12-31T23:59:59")); assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00")); assert_eq!(normalize_iso("2026-02-30"), None); } diff --git a/crates/catalog/src/folders.rs b/crates/catalog/src/folders.rs index 61a8f8c4a..aab3b867b 100644 --- a/crates/catalog/src/folders.rs +++ b/crates/catalog/src/folders.rs @@ -23,15 +23,21 @@ //! (`/Volumes/Macintosh HD/Users/me` and `/Users/me`, `/media/me/usb` and `/run/media/me/usb`) is //! two rows. Each row is still consistent: choosing it shows exactly the photos it counts. //! +//! A folder is otherwise only where photos are, but the library can also keep a +//! [`FolderRecord`] about it (its colour label), stored under the folder's identity +//! ([`crate::query::folder_key`]). A record follows its folder when the folder is renamed or moved +//! here ([`Catalog::folder_records_follow`]); one about a folder that no longer holds a photo is +//! kept (undo can bring the photos back) and shows again when the folder does. +//! //! This is the library's own view of its photos; browsing any folder on disk without importing //! is Local's job (see [`crate::local`]). use std::collections::{BTreeMap, HashMap}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; -use crate::query::{folder_key, folder_within, split_path}; -use crate::{Catalog, Source}; +use crate::query::{folder_key, folder_rest, folder_within, key_within, split_path}; +use crate::{Catalog, ColorLabel, Op, Source}; /// Folder levels kept per photo. No real path comes near it; a deeper one is counted at its /// ancestor this many levels down, which bounds the tree's depth (and so every walk of it) @@ -58,9 +64,35 @@ pub struct FolderNode { /// so for the startup disk and for folders that hold other disks (`/Volumes`, `/mnt`…): their /// path covers those disks' photos too, so choosing them is not offered. pub selectable: bool, + /// The folder's colour label (see [`FolderRecord`]). + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, pub children: Vec, } +/// What the library keeps about one of its folders beyond the photos in it. A record with +/// nothing in it is not kept. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FolderRecord { + /// A colour label, as a photo has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +impl FolderRecord { + /// Whether the record says nothing (and so is not kept). + pub fn is_empty(&self) -> bool { + self.label.is_none() + } +} + +/// The identity a folder's record is kept under: its [`folder_key`]; `None` for a path that +/// names no folder (empty, relative, `.`). +pub(crate) fn record_key(path: &str) -> Option { + let key = folder_key(path); + (split_path(path, true).absolute && !key.is_empty()).then_some(key) +} + /// A folder while the tree is being built (children by identity key). struct Level { name: String, @@ -151,6 +183,53 @@ pub fn folder_label(path: &str) -> String { } impl Catalog { + /// What the library keeps about folder `path`, however it is spelled. + pub fn folder_record(&self, path: &str) -> Option<&FolderRecord> { + record_key(path).and_then(|k| self.folder_records.get(&k)) + } + + /// The colour label of folder `path`, however it is spelled. + pub fn folder_color_label(&self, path: &str) -> Option { + self.folder_record(path).and_then(|r| r.label) + } + + /// The op that gives folder `path` colour label `label` (`None`: takes it off), keeping the + /// rest of its record. Applying it checks the path. + pub fn folder_label_op(&self, path: &str, label: Option) -> Op { + let mut record = self.folder_record(path).cloned().unwrap_or_default(); + record.label = label; + Op::SetFolderRecord { folder: path.to_string(), record: Some(record) } + } + + /// The ops that carry the records of folder `from` and the folders inside it over to `to` + /// (the same place below it), for a folder renamed or moved on disk: commit them with the + /// photos' relinks so undo puts both back. Records already at or below `to` are left from a + /// folder that is no longer there and are dropped (undo restores them). Empty when there is + /// nothing to carry or drop. + pub fn folder_records_follow(&self, from: &str, to: &str) -> Vec { + let (Some(root), Some(dest)) = (record_key(from), record_key(to)) else { return Vec::new() }; + if root == dest { + return Vec::new(); + } + let moved: Vec<(String, FolderRecord)> = self + .folder_records + .iter() + .filter(|(k, _)| key_within(k, &root)) + .filter_map(|(k, r)| { + let rest = folder_rest(k, &root)?; + let base = dest.trim_end_matches('/'); + let path = if rest.is_empty() { dest.clone() } else { format!("{base}/{}", rest.join("/")) }; + Some((folder_key(&path), r.clone())) + }) + .collect(); + // what was known at the old place goes, and so does what is left at the new one (also + // when the moved folder carries nothing: the folder now there is not the one it was about) + let stale = self.folder_records.keys().filter(|k| key_within(k, &root) || key_within(k, &dest)); + let mut ops: Vec = stale.map(|k| Op::SetFolderRecord { folder: k.clone(), record: None }).collect(); + ops.extend(moved.into_iter().map(|(k, r)| Op::SetFolderRecord { folder: k, record: Some(r) })); + ops + } + /// The library's volumes with their folders and photo counts: volumes by name /// (case-insensitive), each folder with its subfolders the same way. pub fn folder_tree(&self) -> Vec { @@ -196,7 +275,7 @@ impl Catalog { l.own += n; } } - let mut tree: Vec = roots.iter().filter_map(|k| build(k, &levels)).collect(); + let mut tree: Vec = roots.iter().filter_map(|k| build(k, &levels, &self.folder_records)).collect(); sort(&mut tree); // a folder that holds another disk's mount covers that disk's photos too let mounts: Vec = tree.iter().filter(|v| v.path != "/").map(|v| v.path.clone()).collect(); @@ -206,12 +285,21 @@ impl Catalog { } /// The node for `key` with everything below it (at most [`MAX_DEPTH`] deep). -fn build(key: &str, levels: &HashMap) -> Option { +fn build(key: &str, levels: &HashMap, records: &BTreeMap) -> Option { let l = levels.get(key)?; - let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels)).collect(); + let mut children: Vec = l.children.iter().filter_map(|k| build(k, levels, records)).collect(); sort(&mut children); let count = l.own + children.iter().map(|c| c.count).sum::(); - Some(FolderNode { name: l.name.clone(), path: l.path.clone(), count, own: l.own, volume: l.volume, selectable: true, children }) + Some(FolderNode { + name: l.name.clone(), + path: l.path.clone(), + count, + own: l.own, + volume: l.volume, + selectable: true, + label: records.get(key).and_then(|r| r.label), + children, + }) } fn mark_selectable(nodes: &mut [FolderNode], mounts: &[String]) { diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 5973975d6..f52d08db8 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -46,6 +46,9 @@ //! | 1 | up to v0.2.0 | | //! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` | //! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` | +//! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` | +//! | 5 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` | +//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -70,7 +73,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 = 3; +pub const VERSION: u32 = 6; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index dde020157..92a531951 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -5,8 +5,14 @@ //! changes — so they are a single undo step and replay from the op log like any other edit. //! Keyword names compare case-insensitively; renaming a keyword renames its children too //! (`travel|italy` → `trips|italy`). +//! +//! The **keyword list** holds keywords on their own ([`Op::SetKeyword`]): created before any +//! photo has them, or given attributes ([`KeywordInfo`]: synonyms, export options, person). The +//! tree is the keyword list together with the keywords photos carry. -use serde::Serialize; +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; use crate::{Catalog, CatalogError, Meta, Op, Result}; @@ -18,16 +24,135 @@ pub fn clean(k: &str) -> String { k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect::>().join("|") } +/// The same name, whatever the case of any of its letters (not only ASCII ones). +pub fn same(a: &str, b: &str) -> bool { + a == b || a.to_lowercase() == b.to_lowercase() +} + /// `k` is `parent` or one of its descendants (`parent|…`), ignoring case. pub fn is_under(k: &str, parent: &str) -> bool { let (k, p) = (k.to_lowercase(), parent.to_lowercase()); k == p || k.strip_prefix(&p).is_some_and(|rest| rest.starts_with(SEP)) } -/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. -fn reparent(k: &str, from: &str, to: &str) -> String { - let rest = &k[from.len().min(k.len())..]; - if to.is_empty() { rest.trim_start_matches(SEP).to_string() } else { format!("{to}{rest}") } +/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. By levels, not bytes: +/// `from` may be written in another case, which can change a letter's length (ẞ / ß). +pub fn reparent(k: &str, from: &str, to: &str) -> String { + let skip = from.split(SEP).filter(|s| !s.trim().is_empty()).count(); + let rest: Vec<&str> = k.split(SEP).filter(|s| !s.trim().is_empty()).skip(skip).collect(); + std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") +} + +/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, then the +/// names of `dc:subject` that aren't levels of those paths (Lightroom Classic writes each keyword's +/// name, its parents' and synonyms flat besides its path). Each once, cleaned. +pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec { + let mut out: Vec = hierarchical.iter().map(|k| clean(k)).collect(); + out.extend(flat.iter().map(|k| clean(k)).filter(|k| !hierarchical.iter().any(|h| h.split(SEP).any(|level| same(level.trim(), k))))); + dedupe(&mut out); + out +} + +/// The deepest a keyword list file may nest keywords: a file is input, so its nesting is bounded. +pub const MAX_LEVELS: usize = 64; + +/// A keyword list as written by [`Catalog::keyword_list_text`]: the file's text, the keywords in +/// it (paths), and what the format can't hold and so is left out (a keyword, with those inside it, +/// as its path; a synonym as `path {synonym}`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct KeywordListFile { + pub text: String, + pub written: Vec, + pub unwritable: Vec, +} + +/// Read a keyword list file (Lightroom Classic's format): a keyword a line, those inside it +/// indented one tab more, a synonym in braces on a line of its own under its keyword, a keyword +/// left out of export in square brackets. Gives each keyword's path and attributes, in the order of +/// the file; a keyword twice is one. A line it can't read is an error that says which. +pub fn parse_keyword_list(text: &str) -> Result> { + let bad = |n: usize, why: &str| CatalogError::Invalid(format!("line {n}: {why}")); + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + // Windows, Unix and classic Mac line ends alike + let text = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut out: Vec<(String, KeywordInfo)> = Vec::new(); + // where each keyword is in `out`, by lower-case path (a search of `out` per line was quadratic) + let mut index: std::collections::HashMap = std::collections::HashMap::new(); + // the keyword at each level above the line: its path and where it is in `out` + let mut above: Vec<(String, usize)> = Vec::new(); + for (i, line) in text.split('\n').enumerate() { + let n = i + 1; + if line.trim().is_empty() { + continue; + } + let depth = line.chars().take_while(|c| *c == '\t').count(); + let rest = line.trim_start_matches('\t'); + // levels are tabs: spaces would flatten the hierarchy without a word + if rest.starts_with(' ') { + return Err(bad(n, "keywords are indented with tabs, one per level, not spaces")); + } + if rest.chars().any(char::is_control) { + return Err(bad(n, "a control character in a keyword")); + } + let body = rest.trim(); + if let Some(synonym) = body.strip_prefix('{').and_then(|b| b.strip_suffix('}')) { + let Some(at) = depth.checked_sub(1).and_then(|d| above.get(d)).map(|(_, at)| *at) else { + return Err(bad(n, "a synonym (in braces) goes one tab under its keyword")); + }; + let synonym = synonym.trim(); + if synonym.contains(SEP) { + return Err(bad(n, "“|” separates a keyword's levels: a synonym can't hold one")); + } + if let Some((_, info)) = out.get_mut(at) + && !synonym.is_empty() + && !info.synonyms.iter().any(|s| same(s, synonym)) + { + info.synonyms.push(synonym.to_string()); + } + continue; + } + if depth > above.len() { + return Err(bad(n, "indented more than one tab below the keyword above it")); + } + if depth >= MAX_LEVELS { + return Err(bad(n, &format!("keywords nest {MAX_LEVELS} levels deep at most"))); + } + let (name, left_out) = match body.strip_prefix('[').and_then(|b| b.strip_suffix(']')) { + Some(name) => (name.trim(), true), + None => (body, false), + }; + if name.is_empty() { + return Err(bad(n, "a keyword needs a name")); + } + if name.contains(SEP) { + return Err(bad(n, "“|” separates a keyword's levels: a name can't hold one")); + } + above.truncate(depth); + let path = match above.last() { + Some((parent, _)) => format!("{parent}{SEP}{name}"), + None => name.to_string(), + }; + let at = match index.get(&path.to_lowercase()) { + Some(at) => *at, + None => { + out.push((path.clone(), KeywordInfo::default())); + index.insert(path.to_lowercase(), out.len() - 1); + out.len() - 1 + } + }; + if left_out && let Some((_, info)) = out.get_mut(at) { + info.include_on_export = false; + } + // as first spelled: the keywords inside a repeat take that spelling + let spelled = out.get(at).map_or(path, |(p, _)| p.clone()); + above.push((spelled, at)); + } + Ok(out) +} + +/// The keyword of `from` that `k` is under, the closest one when several are (`a|b` before `a`). +pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> { + from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count()) } /// Keep the first of case-insensitively equal keywords. @@ -36,6 +161,66 @@ fn dedupe(v: &mut Vec) { v.retain(|k| !k.is_empty() && seen.insert(k.to_lowercase())); } +/// What the keyword list knows about a keyword besides the photos that carry it: Lightroom +/// Classic's keyword tag options. A keyword is listed when it was created on its own or given +/// attributes; one that only photos carry has the defaults. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeywordInfo { + /// Other words for it, exported with it (when `export_synonyms`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub synonyms: Vec, + /// Written into exported files at all. + #[serde(default = "yes")] + pub include_on_export: bool, + /// The keywords containing it are exported with it (`travel|italy` also gives `travel`). + #[serde(default = "yes")] + pub export_containing: bool, + #[serde(default = "yes")] + pub export_synonyms: bool, + /// The keyword names a person. + #[serde(default)] + pub person: bool, + /// New keywords go inside this one (Put New Keywords Inside This Keyword): one keyword at + /// most, see [`Catalog::default_keyword_parent`]. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub new_keywords_inside: bool, +} + +fn yes() -> bool { + true +} + +impl Default for KeywordInfo { + fn default() -> Self { + KeywordInfo { + synonyms: Vec::new(), + include_on_export: true, + export_containing: true, + export_synonyms: true, + person: false, + new_keywords_inside: false, + } + } +} + +/// A keyword in the library's keyword list: its path as written and its attributes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListedKeyword { + pub path: String, + pub info: KeywordInfo, +} + +/// A photo's keywords as an exported file carries them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ExportKeywords { + /// Names, flat (`dc:subject`): each keyword's own, the keywords containing it and synonyms, as + /// their options say. + pub flat: Vec, + /// Full paths (`lr:hierarchicalSubject`). + pub hierarchical: Vec, +} + /// One node of the keyword tree. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct KeywordNode { @@ -63,9 +248,12 @@ impl Catalog { } let mut levels: HashMap = HashMap::new(); let mut this_photo: HashSet = HashSet::new(); - for p in self.photos().filter(|p| p.in_library()) { + // the keyword list first (no photos counted): a listed keyword is there without photos + let listed: Vec = self.listed_keywords().map(|k| k.path.clone()).collect(); + let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice())); + for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) { this_photo.clear(); - for k in &p.meta.keywords { + for k in keywords { let mut path = String::new(); let mut lower = String::new(); let mut parent: Option = None; @@ -82,7 +270,7 @@ impl Catalog { count: 0, parent: parent.clone(), }); - if this_photo.insert(lower.clone()) { + if counts && this_photo.insert(lower.clone()) { l.count += 1; } parent = Some(lower.clone()); @@ -118,52 +306,434 @@ impl Catalog { .collect() } - /// Rename `from` (and the keywords below it) to `to` on every photo — one batch. Renaming onto - /// an existing keyword merges the two. + /// `SetKeyword` ops turning the keyword list into what `f` makes of it (lower-case path → + /// listing): what it no longer has is taken off first, then what is new or changed is set. + fn list_ops(&self, f: impl FnOnce(&mut BTreeMap)) -> Vec { + let before = &self.keyword_list; + let mut after = before.clone(); + f(&mut after); + let gone = before.iter().filter(|(k, _)| !after.contains_key(*k)).map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: None }); + let set = after + .iter() + .filter(|(k, l)| before.get(*k) != Some(*l)) + .map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: Some(l.info.clone()) }); + gone.chain(set).collect() + } + + /// The keyword list once the keywords under each of `from` move to `to`: a listing landing on + /// one already there merges into it (that one's attributes stay, the synonyms gather). + fn list_move_ops(&self, from: &[String], to: &str) -> Vec { + self.list_ops(|list| { + // every moving listing leaves first, then each lands: one landing where another is + // about to leave isn't merged into it + let moving: Vec = list.iter().filter(|(_, l)| closest(&l.path, from).is_some()).map(|(k, _)| k.clone()).collect(); + let left: Vec = moving.iter().filter_map(|key| list.remove(key)).collect(); + for l in left { + let Some(f) = closest(&l.path, from) else { continue }; + let path = reparent(&l.path, f, to); + match list.get_mut(&path.to_lowercase()) { + Some(there) => { + there.info.new_keywords_inside |= l.info.new_keywords_inside; + for s in l.info.synonyms { + if !there.info.synonyms.iter().any(|x| same(x, &s)) { + there.info.synonyms.push(s); + } + } + } + None => { + list.insert(path.to_lowercase(), ListedKeyword { path, info: l.info }); + } + } + } + }) + } + + /// Rename `from` (and the keywords below it) to `to` on every photo and in the keyword list — + /// one batch. Renaming onto an existing keyword merges the two. pub fn rename_keyword_ops(&self, from: &str, to: &str) -> Result { let (from, to) = (clean(from), clean(to)); if from.is_empty() || to.is_empty() { return Err(CatalogError::Invalid("keyword names can't be empty".into())); } - if is_under(&to, &from) && !to.eq_ignore_ascii_case(&from) { + if is_under(&to, &from) && !same(&to, &from) { return Err(CatalogError::Invalid("can't move a keyword below itself".into())); } - let ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect()); + let mut ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect()); + ops.extend(self.list_move_ops(std::slice::from_ref(&from), &to)); Ok(Op::Batch { ops }) } - /// Remove `keyword` and the keywords below it from every photo. + /// Remove `keyword` and the keywords below it from every photo and from the keyword list. pub fn delete_keyword_ops(&self, keyword: &str) -> Result { let k = clean(keyword); if k.is_empty() { return Err(CatalogError::Invalid("empty keyword".into())); } - Ok(Op::Batch { ops: self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect()) }) + let mut ops = self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect()); + ops.extend(self.list_ops(|list| list.retain(|_, l| !is_under(&l.path, &k)))); + Ok(Op::Batch { ops }) } /// Merge several keywords (with their children) into `into`. pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result { let into = clean(into); - let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case(&into)).collect(); + let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !same(f, &into)).collect(); if into.is_empty() || from.is_empty() { return Err(CatalogError::Invalid("merge needs keywords and a target".into())); } if from.iter().any(|f| is_under(&into, f)) { return Err(CatalogError::Invalid("can't merge a keyword into one below it".into())); } - let ops = self.keyword_ops(|kws| { - kws.iter().map(|k| from.iter().find(|f| is_under(k, f)).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect() - }); + let mut ops = + self.keyword_ops(|kws| kws.iter().map(|k| closest(k, &from).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect()); + ops.extend(self.list_move_ops(&from, &into)); Ok(Op::Batch { ops }) } + /// What exported files carry for a photo with these keywords (Lightroom Classic's keyword tag + /// options, [`KeywordInfo`]): a keyword left out of export goes nowhere; otherwise its name + /// and full path, the keywords containing it that are exported themselves (unless "export + /// containing keywords" is off) and synonyms (unless "export synonyms" is off). Each name once, + /// whatever its case. + pub fn export_keywords(&self, keywords: &[String]) -> ExportKeywords { + let defaults = KeywordInfo::default(); + let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults); + let mut out = ExportKeywords::default(); + let push = |v: &mut Vec, s: &str| { + if !s.is_empty() && !v.iter().any(|x| same(x, s)) { + v.push(s.to_string()); + } + }; + for k in keywords { + let path = clean(k); + let me = info(&path); + if path.is_empty() || !me.include_on_export { + continue; + } + let levels: Vec<&str> = path.split(SEP).collect(); + let mut names = |at: usize, i: &KeywordInfo| { + if let Some(name) = levels.get(at) { + push(&mut out.flat, name); + } + if i.export_synonyms { + for s in &i.synonyms { + push(&mut out.flat, s.trim()); + } + } + }; + names(levels.len().saturating_sub(1), me); + if me.export_containing { + for at in (0..levels.len().saturating_sub(1)).rev() { + let parent = levels.get(..=at).map(|l| l.join("|")).unwrap_or_default(); + let pi = info(&parent); + if pi.include_on_export { + names(at, pi); + } + } + } + push(&mut out.hierarchical, &path); + } + out + } + + /// The keyword list as a file (Lightroom Classic's format, [`parse_keyword_list`]): every + /// keyword of the tree, those inside one indented a tab more, synonyms in braces under their + /// keyword, a keyword left out of export in square brackets; by name. What the format can't hold + /// is left out and named, so the file always reads back. + pub fn keyword_list_text(&self) -> KeywordListFile { + // a name the list would read as something else, or not at all + fn unwritable(name: &str) -> bool { + name.trim().is_empty() + || name != name.trim() + || name.chars().any(char::is_control) + // a byte-order mark: on the first line the reader takes it for the file's own + || name.starts_with('\u{feff}') + || (name.starts_with('[') && name.ends_with(']')) + || (name.starts_with('{') && name.ends_with('}')) + } + fn write(c: &Catalog, nodes: &[KeywordNode], depth: usize, file: &mut KeywordListFile) { + let tabs = "\t".repeat(depth); + for n in nodes { + // the keywords inside go with it: under its parent they'd be other keywords + if depth >= MAX_LEVELS || unwritable(&n.name) { + file.unwritable.push(n.path.clone()); + continue; + } + let info = c.keyword_info(&n.path).cloned().unwrap_or_default(); + if info.include_on_export { + file.text.push_str(&format!("{tabs}{}\n", n.name)); + } else { + file.text.push_str(&format!("{tabs}[{}]\n", n.name)); + } + file.written.push(n.path.clone()); + // the reader trims synonyms and takes one per spelling: an empty one, or another's + // in a different case, is no loss; a line break or a “|” is + let mut synonyms: Vec<&str> = Vec::new(); + for s in info.synonyms.iter().map(|s| s.trim()) { + if s.is_empty() || synonyms.iter().any(|x| same(x, s)) { + continue; + } + if s.chars().any(char::is_control) || s.contains(SEP) { + file.unwritable.push(format!("{} {{{s}}}", n.path)); + } else { + synonyms.push(s); + file.text.push_str(&format!("{tabs}\t{{{s}}}\n")); + } + } + write(c, &n.children, depth + 1, file); + } + } + let mut file = KeywordListFile::default(); + write(self, &self.keyword_tree(), 0, &mut file); + file + } + + /// Import a keyword list (read with [`parse_keyword_list`]) — one batch: the keywords the + /// library doesn't have are added with the list's attributes, in the library's spelling of the + /// levels it has; those it has keep their attributes and gain the list's synonyms. Also says how + /// many keywords were added and how many gained synonyms. + pub fn import_keywords_ops(&self, entries: &[(String, KeywordInfo)]) -> (Op, usize, usize) { + // every keyword the library has, by lower-case path, as it spells it: learnt once from the + // tree (looking each line up among all the photos' keywords took minutes for big lists) + let mut known: std::collections::HashMap = std::collections::HashMap::new(); + fn learn(nodes: &[KeywordNode], known: &mut std::collections::HashMap) { + for n in nodes { + known.insert(n.path.to_lowercase(), n.path.clone()); + learn(&n.children, known); + } + } + learn(&self.keyword_tree(), &mut known); + // a path in the library's spelling of each level it has + let spell = |path: &str, known: &std::collections::HashMap| -> String { + let levels: Vec<&str> = path.split(SEP).collect(); + let mut out = String::new(); + for (i, level) in levels.iter().enumerate() { + if !out.is_empty() { + out.push(SEP); + } + let prefix = levels.get(..=i).map(|l| l.join("|").to_lowercase()).unwrap_or_default(); + match known.get(&prefix).and_then(|p| p.rsplit(SEP).next()) { + Some(name) => out.push_str(name), + None => out.push_str(level), + } + } + out + }; + let (mut added, mut updated) = (0, 0); + let ops = self.list_ops(|list| { + for (path, info) in entries { + let path = spell(&clean(path), &known); + let key = path.to_lowercase(); + if path.is_empty() { + continue; + } + if !known.contains_key(&key) { + list.insert(key.clone(), ListedKeyword { path: path.clone(), info: KeywordInfo { new_keywords_inside: false, ..info.clone() } }); + // it and its parents are the library's now: what follows spells them so + let levels: Vec<&str> = path.split(SEP).collect(); + for i in 0..levels.len() { + let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default(); + known.entry(prefix.to_lowercase()).or_insert(prefix); + } + added += 1; + continue; + } + // one the library has: the list's synonyms join its own (listed only if it gains some) + let mut info_now = list.get(&key).map(|l| l.info.clone()).unwrap_or_default(); + let before = info_now.synonyms.len(); + for s in &info.synonyms { + if !info_now.synonyms.iter().any(|x| same(x, s)) { + info_now.synonyms.push(s.clone()); + } + } + if info_now.synonyms.len() > before { + let path = list.get(&key).map_or(path, |l| l.path.clone()); + list.insert(key, ListedKeyword { path, info: info_now }); + updated += 1; + } + } + }); + (Op::Batch { ops }, added, updated) + } + + /// The keyword is in the tree: listed or on a library photo (not one in Recently Deleted or + /// only browsed), itself or one below it. + pub fn has_keyword(&self, path: &str) -> bool { + self.keyword_path(path).is_some() + } + + /// Where a keyword typed for a photo goes: inside the default parent when it is a new name (Put + /// New Keywords Inside This Keyword); a keyword the library has, or a path typed whole, as + /// typed (cleaned). + pub fn typed_keyword(&self, typed: &str) -> String { + let k = clean(typed); + match self.default_keyword_parent() { + Some(parent) if !k.is_empty() && !k.contains(SEP) && !self.has_keyword(&k) => format!("{parent}{SEP}{k}"), + _ => k, + } + } + + /// `path` with each level the library has spelled as the library spells it (`TRAVEL|Rome` next + /// to `travel|italy` is `travel|Rome`). + fn spelled(&self, path: &str) -> String { + let levels: Vec<&str> = path.split(SEP).collect(); + let mut out = String::new(); + for (i, level) in levels.iter().enumerate() { + let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default(); + let name = self.keyword_path(&prefix).and_then(|p| p.rsplit(SEP).next().map(str::to_string)).unwrap_or_else(|| level.to_string()); + if !out.is_empty() { + out.push(SEP); + } + out.push_str(&name); + } + out + } + + /// The keyword new keywords go inside (Put New Keywords Inside This Keyword), if any. + pub fn default_keyword_parent(&self) -> Option { + self.keyword_list.values().find(|l| l.info.new_keywords_inside).map(|l| l.path.clone()) + } + + /// Make `keyword` the one new keywords go inside (`None`: none) — one batch. It is listed if it + /// wasn't, keeping the attributes it has. + pub fn set_default_parent_ops(&self, keyword: Option<&str>) -> Result { + let path = match keyword { + Some(k) => Some(self.keyword_path(k).ok_or_else(|| CatalogError::Invalid(format!("no keyword “{}”", clean(k))))?), + None => None, + }; + Ok(Op::Batch { + ops: self.list_ops(|list| { + for l in list.values_mut() { + l.info.new_keywords_inside = false; + } + if let Some(path) = path { + let l = list.entry(path.to_lowercase()).or_insert_with(|| ListedKeyword { path, info: KeywordInfo::default() }); + l.info.new_keywords_inside = true; + } + }), + }) + } + + /// The keyword as written in the library (whatever the case of `path`): its listing, or the + /// levels a photo's keyword spells it with. `None` when there is no such keyword. + pub fn keyword_path(&self, path: &str) -> Option { + let k = clean(path); + if k.is_empty() { + return None; + } + if let Some(l) = self.keyword_list.get(&k.to_lowercase()) { + return Some(l.path.clone()); + } + // the levels of a listed keyword below it, or of a photo's + let levels = k.split(SEP).count(); + let listed = self.keyword_list.values().map(|l| &l.path); + listed + .chain(self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter())) + .find(|x| is_under(x, &k)) + .map(|x| x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::>().join("|")) + } + + /// Create a keyword with these attributes, and give it to `photos` — one batch. A keyword that + /// exists already is refused. + pub fn create_keyword_ops(&self, path: &str, info: KeywordInfo, photos: &[crate::PhotoId]) -> Result { + let path = clean(path); + if path.is_empty() { + return Err(CatalogError::Invalid("keyword names can't be empty".into())); + } + if self.has_keyword(&path) { + return Err(CatalogError::KeywordExists(path)); + } + let path = self.spelled(&path); + let mut ops = vec![Op::SetKeyword { path: path.clone(), info: Some(info) }]; + for id in photos { + let p = self.photo(*id).ok_or(CatalogError::NoPhoto(*id))?; + let mut meta = p.meta.clone(); + meta.keywords.push(path.clone()); + dedupe(&mut meta.keywords); + ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) }); + } + Ok(Op::Batch { ops }) + } + + /// Move `keyword` (with the keywords below it) inside `parent`, or to the top level (`None`). + /// It keeps its name. Onto a keyword that is there already the two merge, which only happens + /// with `merge`; otherwise [`CatalogError::KeywordExists`]. + pub fn move_keyword_ops(&self, keyword: &str, parent: Option<&str>, merge: bool) -> Result { + let Some(from) = self.keyword_path(keyword) else { + return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword)))); + }; + let leaf = from.rsplit(SEP).next().unwrap_or(&from); + let to = match parent.map(clean).filter(|p| !p.is_empty()) { + Some(p) if is_under(&p, &from) => return Err(CatalogError::Invalid("can't move a keyword inside itself".into())), + Some(p) => format!("{}{SEP}{leaf}", self.keyword_path(&p).unwrap_or(p)), + None => leaf.to_string(), + }; + if same(&to, &from) { + return Ok(Op::Batch { ops: vec![] }); + } + if !merge && self.has_keyword(&to) { + return Err(CatalogError::KeywordExists(to)); + } + self.rename_keyword_ops(&from, &to) + } + + /// Rename `keyword`'s last level to `name` (it stays where it is) and set its attributes — one + /// batch. A name that is taken is refused ([`CatalogError::KeywordExists`]): merging is its own + /// action. + pub fn edit_keyword_ops(&self, keyword: &str, name: &str, info: KeywordInfo) -> Result { + let Some(from) = self.keyword_path(keyword) else { + return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword)))); + }; + let name = name.trim(); + if name.is_empty() || name.contains(SEP) { + return Err(CatalogError::Invalid("a keyword's name is one level, without “|”".into())); + } + let to = match from.rsplit_once(SEP) { + Some((parent, _)) => format!("{parent}{SEP}{name}"), + None => name.to_string(), + }; + let mut ops = Vec::new(); + if to != from { + if !same(&to, &from) && self.has_keyword(&to) { + return Err(CatalogError::KeywordExists(to)); + } + if let Op::Batch { ops: renamed } = self.rename_keyword_ops(&from, &to)? { + ops = renamed; + } + } + // set the attributes only when they change something: an unlisted keyword with the default + // ones stays unlisted + let now = self.keyword_info(&from).cloned(); + let changed = match &now { + Some(now) => *now != info || to != from, + None => info != KeywordInfo::default(), + }; + if changed { + ops.push(Op::SetKeyword { path: to, info: Some(info) }); + } + Ok(Op::Batch { ops }) + } + + /// Take off the keyword list the keywords no library photo has, nor any keyword below them — one + /// batch. + pub fn purge_unused_keywords_ops(&self) -> Op { + let used: Vec<&String> = self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter()).collect(); + Op::Batch { ops: self.list_ops(|list| list.retain(|_, l| used.iter().any(|k| is_under(k, &l.path)))) } + } + /// Keyword suggestions for a photo that has `current` keywords: with a typed `prefix`, the /// library's keywords containing it (those starting with it first); otherwise keywords that /// appear together with `current` on other photos, then the most used ones. Most frequent /// first, at most `n`, never one of `current`. pub fn keyword_suggestions(&self, current: &[String], prefix: &str, n: usize) -> Vec { - let all = self.keywords(); - let has = |k: &str| current.iter().any(|c| c.eq_ignore_ascii_case(k)); + // the photos' keywords with their counts, and those only the list has (none) + let mut all = self.keywords(); + for l in self.keyword_list.values() { + if !all.iter().any(|(k, _)| same(k, &l.path)) { + all.push((l.path.clone(), 0)); + } + } + let has = |k: &str| current.iter().any(|c| same(c, k)); let q = prefix.trim().to_lowercase(); let mut scored: Vec<(i64, String)> = if !q.is_empty() { all.into_iter() @@ -263,6 +833,600 @@ mod tests { assert_eq!(clean(" a | |b "), "a|b"); } + fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> { + let mut out = Vec::new(); + for n in nodes { + out.push((n.path.clone(), n.count)); + out.extend(tree_paths(&n.children)); + } + out + } + + /// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag): + /// it is in the tree with no photos, its parents too, and undo takes it away. + #[test] + fn a_keyword_listed_with_no_photos_is_in_the_tree() { + let (mut c, _) = lib(&[&["travel|France"]]); + let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]); + assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case"); + c.apply(undo).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]); + assert_eq!(c.keyword_info("travel|Italy"), None); + } + + /// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported, + /// with the keywords containing it and its synonyms; not a person. + #[test] + fn a_listed_keyword_needs_a_name_and_starts_exported() { + let mut c = Catalog::new(); + assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err()); + let info = KeywordInfo::default(); + assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty()); + } + + /// Changing a listed keyword's attributes is undone to the attributes it had, under the name as + /// it was written. + #[test] + fn changing_a_listed_keyword_is_undone_to_what_it_was() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() }; + let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap(); + assert_eq!(c.keyword_info("Weddings"), Some(&person)); + assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }); + c.apply(undo).unwrap(); + assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default())); + } + + fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> { + let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect(); + v.sort_by_key(|(p, _)| p.to_lowercase()); + v + } + + fn with_synonyms(words: &[&str]) -> KeywordInfo { + KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() } + } + + /// Renaming (or moving) a keyword takes its attributes and its listed children along, photos + /// or not, in the same undo step. + #[test] + fn renaming_a_keyword_carries_its_listing_along() { + let (mut c, ids) = lib(&[&["travel|italy"]]); + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap(); + c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let before = c.to_snapshot(); + let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]); + assert_eq!( + listed(&c), + [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())] + ); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + } + + /// A keyword only the list holds (no photos) can be renamed too. + #[test] + fn a_keyword_without_photos_can_be_renamed() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]); + } + + /// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target + /// not listed yet takes the merged keyword's attributes. + #[test] + fn merging_keeps_the_targets_attributes_and_gathers_synonyms() { + let (mut c, _) = lib(&[&["holiday"], &["travel"]]); + let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) }; + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { + path: "holiday".into(), + info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }), + }) + .unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]); + // onto a keyword that isn't listed: it takes the merged one's attributes + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]); + } + + /// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos. + #[test] + fn deleting_a_keyword_takes_it_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]); + for path in ["travel", "travel|spain", "beach"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let before = c.to_snapshot(); + let op = c.delete_keyword_ops("travel").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]); + assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + } + + /// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo + /// step. A keyword that exists already (listed, or on a photo) isn't created again. + #[test] + fn creating_a_keyword_lists_it_and_can_tag_photos() { + let (mut c, ids) = lib(&[&["beach"], &[]]); + let before = c.to_snapshot(); + let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]); + assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + for exists in ["BEACH", "events|weddings"] { + if exists == "events|weddings" { + let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + } + assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}"); + } + assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err()); + } + + /// Moving a keyword nests it inside another (or takes it to the top level), with its children, + /// on photos and in the list. It can't go inside itself or one of its children, and moving it + /// where it already is changes nothing. + #[test] + fn moving_a_keyword_nests_it_inside_another() { + let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]); + // typed in any case, the keywords keep the spelling the library has + let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]); + let op = c.move_keyword_ops("europe|italy", None, false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level"); + assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child"); + assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself"); + assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there"); + assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword"); + } + + /// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move + /// asks first: refused unless merging is allowed. + #[test] + fn moving_onto_a_taken_name_merges_only_when_allowed() { + let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]); + let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err(); + assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}"); + let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()])); + } + + /// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes, + /// in one undo step. A name that is taken is refused: merging is its own action. + #[test] + fn editing_a_keyword_renames_it_and_sets_its_attributes() { + let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]); + let before = c.to_snapshot(); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["travel|Italia"]); + assert_eq!(c.keyword_info("travel|italia"), Some(&person)); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + // attributes alone + let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]); + assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_)))); + assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path"); + assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err()); + } + + /// Purging takes off the list the keywords no photo has (nor any keyword below them), in one + /// undo step; keywords photos carry stay. + #[test] + fn purging_takes_unused_keywords_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"]]); + for path in ["travel", "travel|italy", "travel|spain", "weddings"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel", "travel|italy"]); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]); + } + + /// Renaming matches names whatever their case, also where a letter's case changes its length + /// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. + #[test] + fn renaming_keeps_the_levels_below_whatever_the_case() { + let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]); + let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Road|nord"]); + let op = c.rename_keyword_ops("ẞA", "Weg").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); + } + + fn exported(c: &Catalog, keywords: &[&str]) -> (Vec, Vec) { + let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::>()); + (e.flat, e.hierarchical) + } + + /// By default a keyword is exported as its name together with the keywords containing it (flat, + /// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once. + #[test] + fn a_keyword_exports_its_name_its_parents_and_its_path() { + let c = Catalog::new(); + let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]); + assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]); + assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]); + } + + /// A keyword not included on export is left out, path and all; a parent left out (a keyword that + /// only organizes others) isn't exported as a containing keyword, but its children are. + #[test] + fn keywords_left_out_of_export_stay_out() { + let mut c = Catalog::new(); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); + let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]); + assert_eq!(flat, ["Lisbon"]); + assert_eq!(paths, ["Places|Lisbon"]); + } + + /// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go + /// out with it unless "export synonyms" is off. + #[test] + fn containing_keywords_and_synonyms_follow_their_options() { + let mut c = Catalog::new(); + let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) }; + c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap(); + let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) }; + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap(); + let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]); + assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); + } + + /// Names match whatever the case of any letter, not only ASCII ones: changing only the case of + /// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing. + #[test] + fn names_match_whatever_the_case_of_any_letter() { + let (mut c, ids) = lib(&[&["Ärzte|Wien"]]); + let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]); + assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] }); + let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]); + assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); + } + + /// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a + /// keyword like any other: it is in the tree, so it can be found, moved and renamed. + #[test] + fn the_parent_of_a_listed_keyword_is_a_keyword() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert!(c.has_keyword("events")); + assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events")); + let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]); + } + + /// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it + /// can be created again and is purged as unused, and its spelling there isn't the library's. + #[test] + fn deleted_photos_dont_hold_keywords() { + let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]); + c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap(); + assert!(!c.has_keyword("weddings")); + assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok()); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty(), "only a deleted photo has it: unused"); + } + + /// Merging several keywords moves each keyword under them by the closest one, whatever the + /// order they are given in, and every listing lands where its keyword does: one landing where + /// another is about to leave isn't merged into it. + #[test] + fn merging_overlapping_keywords_keeps_every_listing() { + let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap(); + let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()])); + assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]); + // the order of the keywords merged doesn't matter + let merged = |from: [&str; 2]| { + let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]); + let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap(); + c.apply(op).unwrap(); + kws(&c, ids[0]) + }; + assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"])); + assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]); + } + + /// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making + /// another one so is one undo step; the mark moves with its keyword (also into a merge) and + /// goes with it. + #[test] + fn one_keyword_is_where_new_keywords_go() { + let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events")); + let op = c.set_default_parent_ops(Some("travel")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one"); + assert!(c.set_default_parent_ops(Some("lisbon")).is_err()); + let op = c.rename_keyword_ops("travel", "Trips").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips")); + let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge"); + let op = c.delete_keyword_ops("events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent(), None); + let op = c.set_default_parent_ops(None).unwrap(); + assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear"); + c.apply(undo).unwrap(); + assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); + } + + /// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the + /// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes + /// back as one keyword, not three). Without paths, the names as they are. + #[test] + fn a_files_keywords_keep_their_hierarchy() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]); + assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned"); + assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]); + assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); + } + + /// A new keyword inside one the library has takes the library's spelling of it: creating + /// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel". + #[test] + fn a_new_keyword_keeps_the_spelling_of_its_parents() { + let (mut c, _) = lib(&[&["travel|italy"]]); + let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel|Rome"]); + assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel"); + } + + /// An edit that changes nothing is nothing: no listing, no undo step. + #[test] + fn an_edit_that_changes_nothing_is_nothing() { + let (mut c, _) = lib(&[&["beach"]]); + assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] }); + c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap(); + assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] }); + // renamed with the default attributes: the photos change, nothing is listed + let (mut c, _) = lib(&[&["beach"]]); + let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty()); + } + + /// Typing suggests keywords no photo has yet too (created in the Keyword List). + #[test] + fn suggestions_include_keywords_without_photos() { + let (mut c, _) = lib(&[&["beach"]]); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]); + assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has"); + } + + /// A new name typed for a photo goes inside the default parent; a keyword the library has, and + /// a path typed whole, don't. + #[test] + fn typed_new_keywords_go_inside_the_default_parent() { + let (mut c, _) = lib(&[&["beach"], &["Events"]]); + assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent"); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings"); + assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it"); + assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon"); + } + + /// The keyword list as a text file, as Lightroom Classic writes one: a keyword a line, the + /// keywords inside it indented one tab more, a synonym in braces on a line of its own under its + /// keyword, a keyword left out of export in square brackets; by name. + #[test] + fn the_keyword_list_is_written_as_tab_indented_text() { + let (mut c, _) = lib(&[&["Places|Lisbon", "beach"]]); + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(with_synonyms(&["Lisboa"])) }).unwrap(); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(c.keyword_list_text().text, "beach\nEvents\n\tWeddings\n[Places]\n\tLisbon\n\t\t{Lisboa}\n"); + } + + /// Reading a list gives each keyword's path and attributes: what was written comes back, and a + /// file from elsewhere (Windows line ends, a byte-order mark, blank lines) reads too. + #[test] + fn a_keyword_list_reads_back() { + let read = parse_keyword_list("\u{feff}beach\r\n\r\nEvents\r\n\tWeddings\r\n\t\t{marriage}\r\n[Places]\r\n\tLisbon\r\n").unwrap(); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + assert_eq!( + read, + [ + ("beach".to_string(), KeywordInfo::default()), + ("Events".to_string(), KeywordInfo::default()), + ("Events|Weddings".to_string(), with_synonyms(&["marriage"])), + ("Places".to_string(), out), + ("Places|Lisbon".to_string(), KeywordInfo::default()), + ] + ); + } + + /// Photo Supreme's Formatted Vocabulary File is the same format: plain text with Windows line + /// ends and no byte-order mark, a top-level category in square brackets (an organizing keyword, + /// left out of export), keywords a tab further in at each level. + #[test] + fn a_photo_supreme_vocabulary_file_reads() { + let file = "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n\t\t\tAlfama\r\n\t\tPorto\r\n\tSpain\r\n\t\tSeville\r\n"; + let read = parse_keyword_list(file).unwrap(); + let paths: Vec<&str> = read.iter().map(|(p, _)| p.as_str()).collect(); + assert_eq!( + paths, + ["Places", "Portugal", "Portugal|Lisbon", "Portugal|Lisbon|Alfama", "Portugal|Porto", "Spain", "Spain|Seville"] + .map(|p| if p == "Places" { p.to_string() } else { format!("Places|{p}") }) + ); + assert!(!read[0].1.include_on_export, "the category organizes: it isn't exported"); + assert!(read[1..].iter().all(|(_, i)| i.include_on_export)); + } + + /// A list that can't be read says which line and why, and nothing of it is taken. + #[test] + fn a_bad_keyword_list_says_which_line() { + let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); + assert!(err("Events\n\t\tWeddings\n").contains("line 2"), "two levels down at once"); + assert!(err("{marriage}\n").contains("line 1"), "a synonym of nothing"); + assert!(err("Events\nTravel|Lisbon\n").contains("line 2"), "“|” separates levels here"); + let deep: String = (0..70).map(|i| format!("{}k{i}\n", "\t".repeat(i))).collect(); + assert!(err(&deep).contains("line 65"), "a file is input: 64 levels at most"); + } + + /// Files from elsewhere read as they were meant or say why not: classic Mac line ends (a lone + /// carriage return) are lines; indenting with spaces, which would flatten the hierarchy, is + /// refused at its line; so are control characters in a name and "|" in a synonym. + #[test] + fn a_keyword_list_from_elsewhere_reads_or_says_why_not() { + let paths = |text: &str| parse_keyword_list(text).unwrap().into_iter().map(|(p, _)| p).collect::>(); + assert_eq!(paths("Events\r\tWeddings\rPlaces\r"), ["Events", "Events|Weddings", "Places"]); + let err = |text: &str| parse_keyword_list(text).unwrap_err().to_string(); + assert!(err("Events\n Weddings\n").contains("line 2"), "space indentation"); + assert!(err("Events\n\t Weddings\n").contains("line 2"), "a space after the tabs"); + assert!(err("Events\nx\u{0}y\n").contains("line 2"), "a control character"); + assert!(err("Events\n\t{sea|shore}\n").contains("line 2"), "“|” in a synonym"); + } + + /// A keyword repeated in another case is one keyword, and the keywords inside either take the + /// first spelling. + #[test] + fn a_repeated_keyword_keeps_its_first_spelling() { + let read = parse_keyword_list("Events\n\tWeddings\nevents\n\tBirthdays\n").unwrap(); + assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Events", "Events|Weddings", "Events|Birthdays"]); + } + + /// What the list can't hold is left out and named, so the file always reads back: a name the + /// format would read as something else (in brackets or braces), one with a line break, a + /// synonym with one, a keyword nested deeper than a list goes; and the keywords inside one left + /// out go with it. + #[test] + fn an_exported_keyword_list_always_reads_back() { + let deep = (0..=MAX_LEVELS).map(|i| format!("level {i}")).collect::>().join("|"); + let (mut c, _) = lib(&[&["Travel|[draft]", "{todo}|Lisbon", "Weddings", "Line\nbreak", deep.as_str()]]); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["marriage", "two\nlines"])) }).unwrap(); + let KeywordListFile { text, unwritable, .. } = c.keyword_list_text(); + let read = parse_keyword_list(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); + assert!(read.iter().any(|(p, _)| p == "Travel")); + assert!(read.iter().all(|(p, _)| !p.contains("draft") && !p.contains("todo") && !p.contains("Line")), "{text}"); + let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); + assert_eq!(weddings, Some(vec!["marriage".to_string()])); + let deepest = read.iter().map(|(p, _)| p.split(SEP).count()).max(); + assert_eq!(deepest, Some(MAX_LEVELS)); + assert!(unwritable.contains(&"Travel|[draft]".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"{todo}".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"Line\nbreak".to_string()), "{unwritable:?}"); + assert!(unwritable.contains(&"Weddings {two\nlines}".to_string()), "{unwritable:?}"); + assert!(unwritable.iter().any(|u| u.ends_with(&format!("level {MAX_LEVELS}"))), "{unwritable:?}"); + } + + /// The list leaves out only what it must, and names only what's lost: a name starting with a + /// byte-order mark would lose it, but a synonym in braces reads back, and a synonym that's empty + /// or another's in a different case is no loss. It also says which keywords it wrote. + #[test] + fn an_exported_keyword_list_names_only_what_it_loses() { + let (mut c, _) = lib(&[&["\u{feff}Albums", "{draft", "Weddings"]]); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(with_synonyms(&["Marriage", "marriage", " ", "{vows}"])) }).unwrap(); + let out = c.keyword_list_text(); + let read = parse_keyword_list(&out.text).unwrap_or_else(|e| panic!("{e}\n{}", out.text)); + assert_eq!(out.unwritable, ["\u{feff}Albums"]); + assert_eq!(out.written, ["Weddings", "{draft"]); + assert_eq!(read.iter().map(|(p, _)| p.as_str()).collect::>(), ["Weddings", "{draft"]); + let weddings = read.iter().find(|(p, _)| p == "Weddings").map(|(_, i)| i.synonyms.clone()); + assert_eq!(weddings, Some(vec!["Marriage".to_string(), "{vows}".to_string()])); + } + + /// A big list reads and imports at once, against a big library too: 40,000 keywords used to take + /// half a minute to read, and checking 5,000 new ones against 20,000 photos over a minute, with + /// the app frozen. + #[test] + fn a_big_keyword_list_imports_quickly() { + let mut text = String::new(); + for g in 0..400 { + text.push_str(&format!("group {g}\n")); + for k in 0..99 { + text.push_str(&format!("\tkeyword {g}-{k}\n")); + } + } + let t0 = std::time::Instant::now(); + let read = parse_keyword_list(&text).unwrap(); + assert_eq!(read.len(), 40_000); + let mut c = Catalog::new(); + let mut ops = Vec::new(); + for i in 0..20_000u32 { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = (0..5).map(|k| format!("library {}|tag {}", k, (i + k) % 3000)).collect(); + ops.push(Op::AddPhoto { photo: Box::new(p) }); + } + c.apply(Op::Batch { ops }).unwrap(); + let (op, added, _) = c.import_keywords_ops(&read[..5_000]); + c.apply(op).unwrap(); + assert_eq!(added, 5_000); + let took = t0.elapsed(); + assert!(took < std::time::Duration::from_secs(10), "{took:?}"); + } + + /// Importing a list adds the keywords the library doesn't have, with their attributes, and gives + /// those it has the list's synonyms (their own attributes stay), in one undo step. + #[test] + fn importing_a_keyword_list_adds_what_is_missing() { + let (mut c, _) = lib(&[&["Events|Weddings"]]); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo { person: true, ..with_synonyms(&["marriage"]) }) }).unwrap(); + let before = c.to_snapshot(); + let read = parse_keyword_list("events\n\tweddings\n\t\t{nuptials}\n\tBirthdays\n[Drafts]\n").unwrap(); + let (op, added, updated) = c.import_keywords_ops(&read); + assert_eq!((added, updated), (2, 1)); + let undo = c.apply(op).unwrap(); + assert_eq!(c.keyword_info("Events|Weddings"), Some(&KeywordInfo { person: true, ..with_synonyms(&["marriage", "nuptials"]) })); + assert!(c.has_keyword("Events|Birthdays")); + assert_eq!( + listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), + ["Drafts", "Events|Birthdays", "Events|Weddings"], + "in the library's spelling" + ); + assert!(!c.keyword_info("Drafts").unwrap().include_on_export); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 884ec6583..ed6150847 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -30,7 +30,7 @@ use std::collections::BTreeMap; use std::sync::Arc; pub use dates::{DateRun, GroupBy}; -pub use folders::FolderNode; +pub use folders::{FolderNode, FolderRecord}; pub use journal::{Journal, LoadReport, PersistStats, SnapshotPolicy, SnapshotTiming}; pub use keywords::KeywordNode; use lightcraft_develop::DevelopSettings; @@ -52,6 +52,9 @@ pub enum CatalogError { NoStack(StackId), #[error("invalid: {0}")] Invalid(String), + /// A keyword of that name is there already: moving or renaming onto it would merge the two. + #[error("there is a keyword “{0}” already")] + KeywordExists(String), #[error("corrupt catalog data: {0}")] Corrupt(String), #[error("catalog storage: {0}")] @@ -206,6 +209,11 @@ pub enum Op { #[serde(default, skip_serializing_if = "Option::is_none")] preview_only: Option, }, + /// The lens data a photo's file carries (what Reload finds when it was read after import). + SetEmbeddedLens { + id: PhotoId, + lens: Option>, + }, /// The name shown for a colour label (`None` = its colour's name). SetLabelName { label: ColorLabel, @@ -217,6 +225,18 @@ pub enum Op { folder: String, at: Option, }, + /// List a keyword in the library's keyword list with these attributes, or take it off the list + /// (`None`; photos that carry it keep it). Format version 4. + SetKeyword { + path: String, + info: Option, + }, + /// What the library keeps about one of its folders (see [`folders::FolderRecord`]), under + /// its identity ([`query::folder_key`]); `None` or an empty record = keep nothing. + SetFolderRecord { + folder: String, + record: Option, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -239,6 +259,12 @@ pub struct Catalog { /// When each Local folder was last browsed (folder path → ISO 8601), see [`local`]. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] browsed: BTreeMap, + /// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + keyword_list: BTreeMap, + /// What the library keeps about its folders (folder identity → record), see [`folders`]. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + folder_records: BTreeMap, /// Increments on every applied op. #[serde(skip)] pub revision: u64, @@ -324,12 +350,57 @@ impl Catalog { /// no smart album). pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules.matches(p, self), + // 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), None => false, } } + /// 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. + pub fn album_reaches(&self, from: AlbumId, to: AlbumId) -> bool { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut next = vec![from]; + while let Some(a) = next.pop() { + if !seen.insert(a) { + continue; + } + let tested = self.albums.get(&a).and_then(|al| al.smart.as_deref()).map(Filter::albums_tested).unwrap_or_default(); + if tested.contains(&to) { + return true; + } + next.extend(tested); + } + false + } + + /// What is wrong with a saved smart album's rules now (`RuleSet::check`, after + /// `RuleSet::upgrade`): typically a rule testing an album that has since been deleted. Empty + /// for a sound smart album, a plain album or no album. + pub fn smart_album_problems(&self, id: AlbumId) -> Vec { + let Some(filter) = self.albums.get(&id).and_then(|a| a.smart.as_deref()) else { return Vec::new() }; + let mut out: Vec = self.album_filter_problem(filter, Some(id)).into_iter().collect(); + if let Some(mut rules) = filter.rule_set.clone() { + rules.upgrade(); + out.extend(rules.check_for(self, Some(id))); + } + out + } + + /// A loop through `filter`'s own album field (not its rules): smart album `owner` filtered to + /// itself, or to an album that leads back to it. + pub fn album_filter_problem(&self, filter: &Filter, owner: Option) -> Option { + let (a, owner) = (filter.album?, owner?); + (a == owner || self.album_reaches(a, owner)).then(|| rules::Problem { + path: Vec::new(), + field: Some("album".into()), + issue: rules::Issue::AlbumLoop, + message: format!("its album filter (album {}) would make this album include itself", a.0), + }) + } + /// 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) { @@ -366,6 +437,18 @@ impl Catalog { pub fn label_name(&self, l: ColorLabel) -> String { self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}")) } + /// A listed keyword's attributes (any case), `None` when it isn't listed. + pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> { + self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info) + } + /// How the keyword list spells a listed keyword (any case), `None` when it isn't listed. + pub fn listed_path(&self, path: &str) -> Option<&str> { + self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| k.path.as_str()) + } + /// The keyword list: keywords listed on their own or given attributes, by path. + pub fn listed_keywords(&self) -> impl Iterator { + self.keyword_list.values() + } /// The custom name of a colour label, if any. pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> { self.label_names.get(&l).map(String::as_str) @@ -373,7 +456,9 @@ impl Catalog { /// The label a name stands for: a custom name first, then a colour's own name (any case). pub fn label_from_name(&self, name: &str) -> Option { let name = name.trim(); - self.label_names.iter().find(|(_, n)| n.trim().eq_ignore_ascii_case(name)).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name)) + // any case, accents included (Été / été), as rules compare names + let name_lower = name.to_lowercase(); + self.label_names.iter().find(|(_, n)| n.trim().to_lowercase() == name_lower).map(|(l, _)| *l).or_else(|| ColorLabel::parse(name)) } // ---- writes @@ -596,6 +681,10 @@ impl Catalog { preview_only: std::mem::replace(&mut p.preview_only, preview_only), } } + Op::SetEmbeddedLens { id, lens } => { + let p = self.photo_mut(id)?; + Op::SetEmbeddedLens { id, lens: std::mem::replace(&mut p.embedded_lens, lens.map(|l| *l)).map(Box::new) } + } Op::SetFile { id, file_name, source } => { if file_name.trim().is_empty() { return Err(CatalogError::Invalid("empty file name".into())); @@ -612,6 +701,21 @@ impl Catalog { }; Op::SetLabelName { label, name: old } } + Op::SetKeyword { path, info } => { + let path = keywords::clean(&path); + if path.is_empty() { + return Err(CatalogError::Invalid("keyword names can't be empty".into())); + } + let key = path.to_lowercase(); + let old = match info { + Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }), + None => self.keyword_list.remove(&key), + }; + match old { + Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) }, + None => Op::SetKeyword { path, info: None }, + } + } Op::SetBrowsed { folder, at } => { let folder = crate::query::folder_key(&folder); let old = match at { @@ -620,6 +724,14 @@ impl Catalog { }; Op::SetBrowsed { folder, at: old } } + Op::SetFolderRecord { folder, record } => { + let key = folders::record_key(&folder).ok_or_else(|| CatalogError::Invalid(format!("not a folder: {folder}")))?; + let old = match record.filter(|r| !r.is_empty()) { + Some(r) => self.folder_records.insert(key.clone(), r), + None => self.folder_records.remove(&key), + }; + Op::SetFolderRecord { folder: key, record: old } + } Op::Batch { ops } => { let mut inverses = Vec::with_capacity(ops.len()); for op in ops { @@ -721,6 +833,8 @@ mod tests_album_order; #[cfg(test)] mod tests_background; #[cfg(test)] +mod tests_folder_records; +#[cfg(test)] mod tests_folders; #[cfg(test)] mod tests_format_version; diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 4291f45fa..e81d48805 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -283,7 +283,8 @@ impl Op { | Op::SetAnalysis { id, .. } | Op::SetFile { id, .. } | Op::Relink { id, .. } - | Op::SetContent { id, .. } => out(*id), + | Op::SetContent { id, .. } + | Op::SetEmbeddedLens { id, .. } => out(*id), Op::AddAlbum { album } => { album.photos.iter().for_each(|p| out(*p)); album.cover.into_iter().for_each(&mut *out); @@ -299,7 +300,9 @@ impl Op { | Op::SetAlbumRules { .. } | Op::RemoveStack { .. } | Op::SetLabelName { .. } - | Op::SetBrowsed { .. } => {} + | Op::SetKeyword { .. } + | Op::SetBrowsed { .. } + | Op::SetFolderRecord { .. } => {} } } } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index b2a1a6960..199edac1b 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -152,6 +152,17 @@ impl CopyrightStatus { } } +/// A shutter speed as cameras write it (`1/250`, `0.5`, `2"`, `30s`) in seconds; `None` for +/// anything that isn't a positive, finite time. +pub fn parse_shutter_seconds(s: &str) -> Option { + let s = s.trim().trim_end_matches(['s', '"']).trim(); + match s.split_once('/') { + Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?), + None => s.parse().ok(), + } + .filter(|v: &f64| v.is_finite() && *v > 0.0) +} + /// Descriptive + capture metadata. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -296,6 +307,14 @@ pub struct Analysis { pub best: bool, } +/// Raw formats whose readers have vendor white-balance multipliers but no measured camera +/// illuminant: their white balance is developed relative to the as-shot look (6500 K / 0 means +/// as shot), as for rendered photographs. `format` is the file's extension or Lightroom's +/// `fileFormat` name, in any case. See [`Photo::relative_wb`]. +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)) +} + impl Photo { pub fn new(id: PhotoId, source: Source, file_name: &str, format: &str, width: u32, height: u32, imported: &str) -> Photo { Photo { @@ -336,24 +355,27 @@ impl Photo { pub fn develops_raw(&self) -> bool { self.kind == MediaKind::Raw && self.preview_only.is_none() } - /// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF and SRW readers have vendor WB multipliers but no measured camera + /// The current ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF readers have vendor WB multipliers but no measured camera /// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered /// photographs (the engine's `camera_preview::file_local_look` covers the same formats; RWL and /// RAW are Leica's and the oldest Panasonic bodies' names for RW2 files). pub fn relative_wb(&self) -> bool { - self.develops_raw() - && ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f)) + self.develops_raw() && relative_wb_format(&self.format) } /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's - /// default preset applied on top of them ([`Photo::import_look`]). + /// default preset applied on top of them ([`Photo::import_look`]). Like the camera defaults, + /// in the photo's own rendering process. pub fn import_defaults(&self) -> DevelopSettings { match &self.import_look { - Some(l) => (**l).clone(), + Some(l) => DevelopSettings { process: self.develop.process, ..(**l).clone() }, None => self.camera_defaults(), } } /// The built-in defaults for this photo, before any user default preset: raws start from - /// their as-shot white balance; embedded lens corrections on when the file has them. + /// their as-shot white balance; embedded lens corrections on when the file has them. They + /// carry the photo's own rendering process ([`lightcraft_develop::ProcessVersion`]): which + /// process a photo renders with is not an edit, so comparisons with its defaults (edited? + /// still as imported?) don't change when a newer process exists. A new photo has the latest. pub fn camera_defaults(&self) -> DevelopSettings { let wb = if self.relative_wb() { Some((6500.0, 0.0)) } else { self.as_shot_wb }; let mut d = match wb { @@ -363,8 +385,49 @@ impl Photo { if self.embedded_lens.is_some() { d.optics.lens_profile = true; } + d.process = self.develop.process; d } + /// The file name's extension without the dot, as written (`CR2` for `IMG_0042.CR2`); empty + /// when the name has none (`README`, a dot file such as `.hidden`). + pub fn extension(&self) -> &str { + self.file_name.rsplit_once('.').filter(|(stem, _)| !stem.is_empty()).map_or("", |(_, ext)| ext) + } + /// Width and height in pixels as the photo is shown: turned by its orientation and cut by its + /// crop. Straightening and Constrain Crop shrink the crop without changing its shape, so the + /// aspect is exact and the edges can read a little larger than an export. + pub fn shown_size(&self) -> (f64, f64) { + let (w, h) = (self.width as f64, self.height as f64); + let (w, h) = if self.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }; + let r = self.develop.crop.geometry.rect; + // a crop read from a file or an agent is hostile: no NaN, nothing outside the frame + let part = |len: f64| if len.is_finite() { len.clamp(0.0, 1.0) } else { 1.0 }; + (w * part(r.width()), h * part(r.height())) + } + /// Cropped or straightened. + pub fn is_cropped(&self) -> bool { + !self.develop.crop.geometry.is_identity() + } + /// How many keywords the photo has: a hierarchical keyword (`travel|italy|rome`) is one, and + /// the same keyword in different case or with stray spaces counts once; blank ones don't count. + pub fn keyword_count(&self) -> usize { + let mut seen: Vec = self.meta.keywords.iter().map(|k| k.trim().to_lowercase()).filter(|k| !k.is_empty()).collect(); + seen.sort_unstable(); + seen.dedup(); + seen.len() + } + /// The people in the photo: the names on its face regions, trimmed, each once (names that + /// differ only in case are one person, as first seen). Pets and unnamed faces are not people. + pub fn people(&self) -> Vec<&str> { + let mut out: Vec<&str> = Vec::new(); + for r in self.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 }; + if !out.iter().any(|o| o.to_lowercase() == name.to_lowercase()) { + out.push(name); + } + } + out + } /// In the library: not deleted and not only browsed (Local). pub fn in_library(&self) -> bool { !self.deleted && !self.local @@ -439,6 +502,32 @@ mod edited_tests { assert!(!p.is_edited(), "a full reset is unedited too"); } + #[test] + fn the_process_a_photo_renders_with_is_not_an_edit() { + use lightcraft_develop::ProcessVersion; + // a photo on another process than the latest (a V1 photo once a later process is the + // latest, or a photo saved by a newer LightCraft) + let other = ProcessVersion(ProcessVersion::LATEST.0 + 1); + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.dng", "DNG", 10, 10, "2026-10-01T00:00:00"); + p.kind = MediaKind::Raw; + p.as_shot_wb = Some((5200.0, 4.0)); + assert_eq!(p.camera_defaults().process, ProcessVersion::LATEST, "a new photo gets the latest"); + p.develop = Arc::new(DevelopSettings { process: other, ..p.import_defaults() }); + assert_eq!((p.camera_defaults().process, p.import_defaults().process), (other, other)); + assert!(!p.is_edited(), "still as imported"); + // with a default preset's look as well + let mut look = DevelopSettings::for_raw(5200.0, 4.0); + look.light.exposure = 0.3; + p.import_look = Some(Arc::new(look.clone())); + p.develop = Arc::new(DevelopSettings { process: other, ..look }); + assert_eq!(p.import_defaults().process, other); + assert!(!p.is_edited()); + let mut d = (*p.develop).clone(); + d.light.exposure = 1.0; + p.develop = Arc::new(d); + assert!(p.is_edited(), "an edit still is one"); + } + #[test] fn supported_raws_use_relative_white_balance() { for (name, format, relative) in [ @@ -451,6 +540,9 @@ mod edited_tests { ("a.raf", "rAf", true), ("a.cr3", "cr3", true), ("a.srw", "SRW", true), + ("a.cr2", "CR2", true), + ("a.pef", "pef", true), + ("a.orf", "ORF", 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"); diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index c2e27cbde..8b694a021 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -154,12 +154,21 @@ fn token_matches(p: &Photo, tok: &str) -> bool { /// Whether `p` has a named face region called `name` (case-insensitive). fn has_person(p: &Photo, name: &str) -> bool { let name = name.trim().to_lowercase(); - p.meta.regions.iter().any(|r| r.kind == lightcraft_meta::RegionKind::Face && r.name.as_deref().is_some_and(|n| n.to_lowercase() == name)) + p.people().iter().any(|n| n.to_lowercase() == name) } impl Filter { /// Human-readable summary of the active rules (smart album tooltips, `album.list`). pub fn describe(&self) -> String { + self.describe_in(None) + } + + /// [`Filter::describe`] naming the albums its rules test (`RuleSet::describe_with`). + pub fn describe_with(&self, cat: &crate::Catalog) -> String { + self.describe_in(Some(cat)) + } + + fn describe_in(&self, cat: Option<&crate::Catalog>) -> String { let mut v: Vec = Vec::new(); if self.rating > 0 { let op = match self.rating_op { @@ -207,11 +216,18 @@ impl Filter { v.push(format!("“{}”", self.text.trim())); } if let Some(rs) = self.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { - v.push(rs.describe()); + v.push(cat.map_or_else(|| rs.describe(), |c| rs.describe_with(c))); } if v.is_empty() { "all photos".into() } else { v.join(", ") } } + /// The albums this filter tests: its album field and its rules' Album rules. + pub fn albums_tested(&self) -> Vec { + let mut out: Vec = self.album.into_iter().collect(); + out.extend(self.rule_set.as_ref().map(crate::RuleSet::albums_tested).unwrap_or_default()); + out + } + /// Whether matches depend on the clock (only "in the last…" rules do). pub fn depends_on_now(&self) -> bool { self.rule_set.as_ref().is_some_and(crate::RuleSet::depends_on_now) diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index fab8566c1..be1fd867a 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -43,49 +43,494 @@ pub enum Rule { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Kind { Text, - /// Keywords: text ops over each keyword (any keyword matches; `isEmpty` = none). + /// A list of names (keywords, people): text ops over each name (any name matches; `isEmpty` = + /// none). Keywords, Number, Date, - /// One of a fixed set (`choices`). - Choice(&'static [&'static str]), + /// An album or a smart album (not a folder), by id: in it or not. + Album, + /// One of a fixed set: (id, label). Rules store the id; people see the label. + Choice(&'static [(&'static str, &'static str)]), Bool, } -/// The fields rules can test: (id, label, kind). +/// The fields rules can test: (id, label, kind), in field-menu order ([`TOP_LEVEL_FIELDS`], then +/// each of [`FIELD_GROUPS`]). pub const FIELDS: &[(&str, &str, Kind)] = &[ ("rating", "Rating", Kind::Number), - ("flag", "Pick Flag", Kind::Choice(&["pick", "reject", "none"])), - ("label", "Color Label", Kind::Choice(&["red", "yellow", "green", "blue", "purple", "none"])), - ("kind", "File Type", Kind::Choice(&["image", "raw", "video"])), - ("edited", "Has Edits", Kind::Bool), - ("keywords", "Keywords", Kind::Keywords), + ("flag", "Pick Flag", Kind::Choice(&[("pick", "Picked"), ("reject", "Rejected"), ("none", "Unflagged")])), + ( + "label", + "Color Label", + Kind::Choice(&[("red", "Red"), ("yellow", "Yellow"), ("green", "Green"), ("blue", "Blue"), ("purple", "Purple"), ("none", "No Label")]), + ), ("text", "Any Searchable Text", Kind::Text), + // Source + ("album", "Album", Kind::Album), + ("virtualCopy", "Virtual Copy", Kind::Bool), + ("copyName", "Copy Name", Kind::Text), + ("stacked", "In a Stack", Kind::Bool), + // File ("fileName", "Filename", Kind::Text), + ("extension", "File Extension", Kind::Text), ("filePath", "File Path", Kind::Text), + ("kind", "File Type", Kind::Choice(&[("image", "Image"), ("raw", "Raw"), ("video", "Video")])), ("format", "File Format", Kind::Text), - ("title", "Title", Kind::Text), - ("caption", "Caption", Kind::Text), - ("camera", "Camera", Kind::Text), - ("lens", "Lens", Kind::Text), - ("location", "Location", Kind::Text), - ("creator", "Creator", Kind::Text), - ("copyright", "Copyright", Kind::Text), - ("copyrightStatus", "Copyright Status", Kind::Choice(&["copyrighted", "publicDomain", "unknown"])), + ("duration", "Video Duration", Kind::Number), + // Date ("captureDate", "Capture Date", Kind::Date), ("importDate", "Import Date", Kind::Date), ("editDate", "Edit Date", Kind::Date), - ("iso", "ISO Speed", Kind::Number), - ("aperture", "Aperture", Kind::Number), + // Keywords & People + ("keywords", "Keywords", Kind::Keywords), + ("keywordCount", "Keyword Count", Kind::Number), + ("person", "People", Kind::Keywords), + ("personCount", "People Count", Kind::Number), + // Description + ("title", "Title", Kind::Text), + ("caption", "Caption", Kind::Text), + ("altText", "Alt Text", Kind::Text), + ("creator", "Creator", Kind::Text), + ("copyright", "Copyright", Kind::Text), + ( + "copyrightStatus", + "Copyright Status", + Kind::Choice(&[("copyrighted", "Copyrighted"), ("publicDomain", "Public Domain"), ("unknown", "Unknown")]), + ), + // Camera Info + ("camera", "Camera", Kind::Text), + ("lens", "Lens", Kind::Text), ("focalLength", "Focal Length", Kind::Number), - ("megapixels", "Megapixels", Kind::Number), + ("aperture", "Aperture", Kind::Number), + ("shutterSpeed", "Shutter Speed", Kind::Number), + ("iso", "ISO Speed", Kind::Number), + // Location + ("location", "Location", Kind::Text), + ("city", "City", Kind::Text), + ("state", "State / Province", Kind::Text), + ("country", "Country", Kind::Text), ("hasGps", "Has GPS", Kind::Bool), - ("virtualCopy", "Virtual Copy", Kind::Bool), - ("album", "Album", Kind::Number), + // Size + ("longEdge", "Long Edge", Kind::Number), + ("shortEdge", "Short Edge", Kind::Number), + ("aspect", "Aspect Ratio", Kind::Choice(&[("landscape", "Landscape (wide)"), ("portrait", "Portrait (tall)"), ("square", "Square")])), + ("megapixels", "Megapixels", Kind::Number), + // Develop + ("edited", "Has Edits", Kind::Bool), + ("cropped", "Cropped", Kind::Bool), + ("treatment", "Treatment", Kind::Choice(&[("color", "In Color"), ("monochrome", "Black & White")])), + // Assisted Culling ("sharpness", "Focus (assisted culling)", Kind::Number), ("bestOfGroup", "Best of Similar Shots", Kind::Bool), ]; +/// The fields the field menu shows at its top level, before the groups. +pub const TOP_LEVEL_FIELDS: &[&str] = &["rating", "flag", "label", "text"]; + +/// The field menu's submenus: (label, fields), in order. Every field of [`FIELDS`] is either here +/// once or in [`TOP_LEVEL_FIELDS`]. +pub const FIELD_GROUPS: &[(&str, &[&str])] = &[ + ("Source", &["album", "virtualCopy", "copyName", "stacked"]), + ("File", &["fileName", "extension", "filePath", "kind", "format", "duration"]), + ("Date", &["captureDate", "importDate", "editDate"]), + ("Keywords & People", &["keywords", "keywordCount", "person", "personCount"]), + ("Description", &["title", "caption", "altText", "creator", "copyright", "copyrightStatus"]), + ("Camera Info", &["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"]), + ("Location", &["location", "city", "state", "country", "hasGps"]), + ("Size", &["longEdge", "shortEdge", "aspect", "megapixels"]), + ("Develop", &["edited", "cropped", "treatment"]), + ("Assisted Culling", &["sharpness", "bestOfGroup"]), +]; + +/// The field-menu group `field` sits in; `None` for a top-level or unknown field. +pub fn field_group(field: &str) -> Option<&'static str> { + FIELD_GROUPS.iter().find(|g| g.1.contains(&field)).map(|g| g.0) +} + +/// A rule that can't mean anything ([`RuleSet::check`]): where it is, what is wrong in English +/// (for agents and logs), and the field and kind of issue, so the editor can say it in any language. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Problem { + /// Positions from the top rule list down: `[1, 0]` is the first rule of the second rule's group. + pub path: Vec, + /// The rule's field; `None` for a group. + pub field: Option, + pub issue: Issue, + pub message: String, +} + +impl std::fmt::Display for Problem { + /// `rule 2.1: no rating 0–5 is 9` (counting from 1, as people do). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let at: Vec = self.path.iter().map(|i| (i.saturating_add(1)).to_string()).collect(); + // a problem with the filter around the rules (its album field) has no rule to point at + if at.is_empty() { write!(f, "{}", self.message) } else { write!(f, "rule {}: {}", at.join("."), self.message) } + } +} + +/// What kind of thing is wrong with a rule ([`Problem`]); [`Issue::text`] says it after the +/// field's name ("Title: needs something to look for"). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Issue { + UnknownField, + NoSuchOperator, + NotYesNo, + NeedsNumber, + NeedsShutterSpeed, + NeedsDate, + DatesReversed, + NeedsTwoValues, + NeedsCount, + UnknownUnit, + NotAChoice, + NeedsTextOrEmpty, + NeedsTextOrNotEmpty, + /// Keywords and People: their operators say "are empty". + NeedsNamesOrEmpty, + NeedsNamesOrNotEmpty, + NeedsText, + NoRatingMatches, + ChooseAlbum, + NoSuchAlbum, + AlbumLoop, + FolderAlbum, + EmptyGroup, +} + +impl Issue { + pub const ALL: [Issue; 22] = [ + Issue::UnknownField, + Issue::NoSuchOperator, + Issue::NotYesNo, + Issue::NeedsNumber, + Issue::NeedsShutterSpeed, + Issue::NeedsDate, + Issue::DatesReversed, + Issue::NeedsTwoValues, + Issue::NeedsCount, + Issue::UnknownUnit, + Issue::NotAChoice, + Issue::NeedsTextOrEmpty, + Issue::NeedsTextOrNotEmpty, + Issue::NeedsNamesOrEmpty, + Issue::NeedsNamesOrNotEmpty, + Issue::NeedsText, + Issue::NoRatingMatches, + Issue::ChooseAlbum, + Issue::NoSuchAlbum, + Issue::AlbumLoop, + Issue::FolderAlbum, + Issue::EmptyGroup, + ]; + + /// The issue in a few words, to follow the field's name; a message for translation catalogs. + pub fn text(self) -> &'static str { + match self { + Issue::UnknownField => "isn't a field rules know", + Issue::NoSuchOperator => "can't be tested that way", + Issue::NotYesNo => "is yes or no", + Issue::NeedsNumber => "needs a number", + Issue::NeedsShutterSpeed => "needs a time like 1/250 or 2", + Issue::NeedsDate => "needs a date like 2026, 2026-08 or 2026-08-14", + Issue::DatesReversed => "has the first date after the second", + Issue::NeedsTwoValues => "needs two values", + Issue::NeedsCount => "needs a number more than 0", + Issue::UnknownUnit => "needs hours, days, weeks, months or years", + Issue::NotAChoice => "isn't one of the choices", + Issue::NeedsTextOrEmpty => "needs something to look for (or use “is empty”)", + Issue::NeedsTextOrNotEmpty => "needs something to look for (or use “isn't empty”)", + Issue::NeedsNamesOrEmpty => "needs something to look for (or use “are empty”)", + Issue::NeedsNamesOrNotEmpty => "needs something to look for (or use “aren't empty”)", + Issue::NeedsText => "needs text", + Issue::NoRatingMatches => "can't match any rating from 0 to 5", + Issue::ChooseAlbum => "needs an album", + Issue::NoSuchAlbum => "names an album that no longer exists", + Issue::AlbumLoop => "would make this album include itself through other smart albums", + Issue::FolderAlbum => "can't test a folder; choose an album in it", + Issue::EmptyGroup => "This group is empty: add a rule or remove it.", + } + } +} + +/// An issue with its English message, or nothing wrong. +type Found = Option<(Issue, String)>; + +/// What is wrong with one rule, if anything ([`RuleSet::check`]). +fn field_problem(field: &str, op: &str, value: &Value, cat: &Catalog, owner: Option) -> Found { + let Some(kind) = field_kind(field) else { return Some((Issue::UnknownField, format!("unknown field `{field}`"))) }; + if !ops_for(kind).iter().any(|o| o.0 == op) { + return Some((Issue::NoSuchOperator, format!("`{field}` has no operator `{op}`"))); + } + if matches!(op, "isEmpty" | "isNotEmpty") { + return None; + } + match kind { + Kind::Bool => bool_value(value).is_none().then(|| (Issue::NotYesNo, format!("`{field}` is yes or no, not {value}"))), + Kind::Album => album_problem(value, cat, owner), + Kind::Number => { + let each = match value { + Value::Array(a) if op == "between" && a.len() == 2 => a.iter().find_map(|v| number_problem(field, v)), + _ if op == "between" => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two values, not {value}"))), + v => number_problem(field, v), + }; + // a rating rule no rating could meet ("is 9", "≥ 7"); "< 6" is fine, if broad + each.or_else(|| { + let label = ops_for(kind).iter().find(|o| o.0 == op).map_or(op, |o| o.1); + (field == "rating" && !(0..=5).any(|r| num_op(op, Some(f64::from(r)), value))) + .then(|| (Issue::NoRatingMatches, format!("no rating 0–5 {label} {value}"))) + }) + } + Kind::Date => date_problem(field, op, value), + Kind::Choice(choices) => choice_problem(field, value, choices, cat), + Kind::Text | Kind::Keywords => match value { + Value::String(s) if !s.trim().is_empty() => None, + Value::String(_) | Value::Null => { + // the hint names the operator as the menu does: Keywords and People say "are empty" + let not = matches!(op, "isNot" | "notContains"); + let (issue, instead) = match (kind, not) { + (Kind::Keywords, false) => (Issue::NeedsNamesOrEmpty, "are empty"), + (Kind::Keywords, true) => (Issue::NeedsNamesOrNotEmpty, "aren't empty"), + (_, false) => (Issue::NeedsTextOrEmpty, "is empty"), + (_, true) => (Issue::NeedsTextOrNotEmpty, "isn't empty"), + }; + Some((issue, format!("`{field}` needs something to look for (or use “{instead}”)"))) + } + Value::Number(_) => None, + other => Some((Issue::NeedsText, format!("`{field}` needs text, not {other}"))), + }, + } +} + +fn number_problem(field: &str, v: &Value) -> Found { + if field == "shutterSpeed" { + return shutter_value(v).is_null().then(|| (Issue::NeedsShutterSpeed, format!("`shutterSpeed` needs a time like 1/250 or 2, not {v}"))); + } + number(v).filter(|n| n.is_finite()).is_none().then(|| (Issue::NeedsNumber, format!("`{field}` needs a number, not {v}"))) +} + +/// The album an Album rule names: its id as a whole number, written as a number (`3`, `3.0`) or a +/// numeric string (`"3"`). The check, the matcher and the editor all read it this way. +pub fn album_rule_id(value: &Value) -> Option { + number(value).filter(|n| n.is_finite() && *n >= 0.0 && n.fract() == 0.0).map(|n| crate::AlbumId(n as u64)) +} + +/// An Album rule's value: an album that exists and holds photos (a plain or a smart album, not a +/// folder of albums), and, for the rules of `owner`, not one that leads back to it (a smart album +/// testing itself, directly or through others). +fn album_problem(v: &Value, cat: &Catalog, owner: Option) -> Found { + if v.is_null() { + return Some((Issue::ChooseAlbum, "choose an album".into())); + } + match album_rule_id(v).and_then(|id| cat.album(id)) { + None => Some((Issue::NoSuchAlbum, format!("no album {v}"))), + Some(a) if owner.is_some_and(|o| a.id == o || cat.album_reaches(a.id, o)) => { + Some((Issue::AlbumLoop, format!("album {v} would make this album include itself"))) + } + Some(a) if a.folder => Some((Issue::FolderAlbum, format!("album {v} is a folder; choose an album in it"))), + Some(_) => None, + } +} + +/// A rule date in its one reading: `2026`, `2026-08`, `2026-08-14`, then optionally `T10`, +/// `T10:00` or `T10:00:00` (a space works as the `T`), and after whole seconds a fraction and a +/// time zone as imported capture times carry them (`.250`, `+02:00`, `Z`); a real date and time +/// (no month 13, no 30 February, no 25 o'clock). Anything else is [`Issue::NeedsDate`]. The check +/// and the matcher both read dates this way. +pub fn rule_date(s: &str) -> Result { + const PATTERN: &str = "dddd-dd-ddTdd:dd:dd"; + let s: String = s.trim().char_indices().map(|(i, c)| if i == 10 && c == ' ' { 'T' } else { c }).collect(); + let shaped = |t: &str| { + matches!(t.len(), 4 | 7 | 10 | 13 | 16 | 19) + && t.chars().zip(PATTERN.chars()).all(|(c, p)| if p == 'd' { c.is_ascii_digit() } else { c == p }) + }; + // after whole seconds, a fraction and a zone as imports write them (.250, +02:00 or Z) + let tail_ok = |rest: &str| { + let after_fraction = match rest.strip_prefix('.') { + Some(f) => { + let digits = f.bytes().take_while(u8::is_ascii_digit).count(); + if !(1..=9).contains(&digits) { + return false; + } + f.get(digits..).unwrap_or("") + } + None => rest, + }; + let zone = after_fraction.as_bytes(); + match zone { + [] | [b'Z' | b'z'] => true, + [b'+' | b'-', h1, h2, b':', m1, m2] => [h1, h2, m1, m2].iter().all(|c| c.is_ascii_digit()), + _ => false, + } + }; + if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) { + return Err(Issue::NeedsDate); + } + let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::().ok()); + let time_ok = part(11, 13).is_none_or(|h| h < 24) && part(14, 16).is_none_or(|m| m < 60) && part(17, 19).is_none_or(|x| x < 60); + let day = match s.len().min(19) { + 4 => format!("{s}-01-01"), + 7 => format!("{s}-01"), + _ => s.get(..10).unwrap_or("").to_string(), + }; + if time_ok && crate::dates::normalize_iso(&day).is_some() { Ok(s) } else { Err(Issue::NeedsDate) } +} + +fn date_problem(field: &str, op: &str, value: &Value) -> Found { + let date = |v: &Value| match v.as_str().map(rule_date) { + Some(Ok(_)) => None, + _ => Some((Issue::NeedsDate, format!("`{field}` needs a date like 2026, 2026-08 or 2026-08-14, not {v}"))), + }; + match op { + "inLast" | "notInLast" => { + let (n, unit) = match value { + Value::Object(o) => (o.get("n").and_then(number), o.get("unit").cloned().unwrap_or(Value::Null)), + v => (number(v), Value::Null), + }; + if !n.is_some_and(|n| n.is_finite() && n > 0.0) { + return Some((Issue::NeedsCount, format!("`{field}` needs a number of hours, days, weeks, months or years more than 0"))); + } + unit_secs(&unit).is_none().then(|| (Issue::UnknownUnit, format!("`{field}`: unknown unit {unit} (hours, days, weeks, months or years)"))) + } + "between" => match value { + Value::Array(a) if a.len() == 2 => a.iter().find_map(date).or_else(|| { + let bound = |i: usize| a.get(i).and_then(Value::as_str).and_then(|d| rule_date(d).ok()).unwrap_or_default(); + let (from, to) = (bound(0), bound(1)); + (from > to && !from.starts_with(&to)).then(|| (Issue::DatesReversed, format!("`{field}`: the first date is after the second"))) + }), + _ => Some((Issue::NeedsTwoValues, format!("`{field}` between needs two dates, not {value}"))), + }, + _ => date(value), + } +} + +fn choice_problem(field: &str, value: &Value, choices: &[(&str, &str)], cat: &Catalog) -> Found { + let known = value.as_str().is_some_and(|s| { + choices.iter().any(|c| c.0.eq_ignore_ascii_case(s.trim())) + || match field { + "flag" => matches!(s.trim().to_lowercase().as_str(), "picked" | "rejected"), + // a colour's name or the custom name given to it + "label" => cat.label_from_name(s).is_some(), + "copyrightStatus" => crate::CopyrightStatus::parse(s).is_some(), + _ => false, + } + }); + let ids: Vec<&str> = choices.iter().map(|c| c.0).collect(); + (!known).then(|| (Issue::NotAChoice, format!("`{field}` is one of {}, not {value}", ids.join(", ")))) +} + +thread_local! { + /// The smart albums whose rules are being evaluated on this thread, outermost first. + static EVALUATING: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} + +thread_local! { + /// While one outermost question is answered (is this photo in album X?): what each smart album + /// it leads to answered for each photo, so an album tested many times along the way (X tests Y + /// twice, Y tests Z twice…) is worked out once, not 2^depth times. Cleared when it ends. + static ANSWERS: std::cell::RefCell> = + std::cell::RefCell::new(std::collections::HashMap::new()); +} + +/// Whether `photo` is in smart album `id`, by `matches` (its rules), remembered for the rest of the +/// outermost question and guarded by [`evaluating`] (a loop or a chain too deep holds nothing). +pub(crate) fn smart_album_holds(id: crate::AlbumId, photo: crate::PhotoId, matches: impl FnOnce() -> bool) -> bool { + if let Some(known) = ANSWERS.with_borrow(|a| a.get(&(id, photo)).copied()) { + return known; + } + // the outermost question forgets the answers when it ends, however it ends + struct Forget(bool); + impl Drop for Forget { + fn drop(&mut self) { + if self.0 { + ANSWERS.with_borrow_mut(std::collections::HashMap::clear); + } + } + } + let _forget = Forget(EVALUATING.with_borrow(Vec::is_empty)); + let answer = evaluating(id, matches); + if let Some(a) = answer { + ANSWERS.with_borrow_mut(|known| known.insert((id, photo), a)); + } + answer.unwrap_or(false) +} + +/// How deep smart albums may test smart albums before the innermost counts as holding nothing. +const MAX_ALBUM_DEPTH: usize = 32; + +/// Run `f` as the evaluation of smart album `id`'s rules: `None` (so it holds nothing) when `id` +/// is already being evaluated (a loop the check would refuse, saved anyway) or the chain is +/// deeper than [`MAX_ALBUM_DEPTH`]. Loops cost one pass round them, never a stack overflow. +pub(crate) fn evaluating(id: crate::AlbumId, f: impl FnOnce() -> T) -> Option { + let entered = EVALUATING.with_borrow_mut(|stack| { + let ok = !stack.contains(&id) && stack.len() < MAX_ALBUM_DEPTH; + if ok { + stack.push(id); + } + ok + }); + if !entered { + return None; + } + // leaves the stack as it was, however `f` ends + struct Leave; + impl Drop for Leave { + fn drop(&mut self) { + EVALUATING.with_borrow_mut(|stack| { + stack.pop(); + }); + } + } + let _leave = Leave; + Some(f()) +} + +/// How a summary names an Album rule's album: “Name”, or `#9` when there is no such album. +pub fn album_name(value: &Value, cat: Option<&Catalog>) -> String { + let id = album_rule_id(value); + match id.and_then(|id| cat.and_then(|c| c.album(id))) { + Some(a) => format!("“{}”", a.name), + None => id.map_or_else(|| value.to_string(), |id| format!("#{}", id.0)), + } +} + +/// What a yes/no rule's value means: `true`, `"yes"`, `1` (or `1.0`) or no value is yes; `false`, +/// `"no"`, `0` is no (strings in any case); `None` for anything else, which matches nothing. +pub fn bool_value(value: &Value) -> Option { + match value { + Value::Bool(b) => Some(*b), + Value::Null => Some(true), + Value::Number(n) => match n.as_f64() { + Some(1.0) => Some(true), + Some(0.0) => Some(false), + _ => None, + }, + Value::String(s) => match s.trim().to_lowercase().as_str() { + "true" | "yes" | "1" => Some(true), + "false" | "no" | "0" => Some(false), + _ => None, + }, + _ => None, + } +} + +/// The display label of a yes/no value. +pub fn bool_label(b: bool) -> &'static str { + if b { "Yes" } else { "No" } +} + +/// The display label of choice `id` of `field` ("Public Domain" for `publicDomain`); `None` when +/// `field` isn't a choice field or has no such choice. +pub fn choice_label(field: &str, id: &str) -> Option<&'static str> { + let Some(Kind::Choice(choices)) = field_kind(field) else { return None }; + choices.iter().find(|c| c.0 == id).map(|c| c.1) +} + +/// The display label of `field`; `None` for an unknown field. +pub fn field_label(field: &str) -> Option<&'static str> { + FIELDS.iter().find(|f| f.0 == field).map(|f| f.1) +} + /// The operators for a field kind: (id, label). pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] { match kind { @@ -119,7 +564,7 @@ pub fn ops_for(kind: Kind) -> &'static [(&'static str, &'static str)] { ("notInLast", "isn't in the last"), ("isEmpty", "is unknown"), ], - Kind::Choice(_) => &[("is", "is"), ("isNot", "isn't")], + Kind::Choice(_) | Kind::Album => &[("is", "is"), ("isNot", "isn't")], Kind::Bool => &[("is", "is")], } } @@ -176,6 +621,18 @@ fn text_op(op: &str, have: &str, want: &str) -> bool { } } +/// A [`Kind::Keywords`] op over a list of names: any name matches (none for `notContains`); `is` +/// also matches one level of a hierarchical keyword (`italy` in `travel|italy|rome`). +fn names_op>(op: &str, names: &[S], want: &str) -> bool { + let names = names.iter().map(AsRef::as_ref); + match op { + "isEmpty" => names.clone().all(|n| n.trim().is_empty()), + "isNotEmpty" => names.clone().any(|n| !n.trim().is_empty()), + "notContains" => !names.clone().any(|n| text_op("contains", n, want)), + _ => names.clone().any(|n| text_op(op, n, want) || (op == "is" && n.to_lowercase().split('|').any(|part| part.trim() == want))), + } +} + fn number(v: &Value) -> Option { match v { Value::Number(n) => n.as_f64(), @@ -204,21 +661,70 @@ fn num_op(op: &str, have: Option, value: &Value) -> bool { } } -/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days. +/// `landscape`, `portrait` or `square` (edges within 1 %) for a `w × h` frame; `None` when it has no area. +fn aspect((w, h): (f64, f64)) -> Option<&'static str> { + if !(w > 0.0 && h > 0.0) { + return None; + } + Some(if (w - h).abs() <= 0.01 * w.max(h) { + "square" + } else if w > h { + "landscape" + } else { + "portrait" + }) +} + +/// A shutter-speed rule's value in seconds: camera notation (`1/250`) or a number; an operand +/// that isn't a time becomes `null`, which matches nothing. +fn shutter_value(value: &Value) -> Value { + let secs = |v: &Value| match v { + Value::String(s) => crate::parse_shutter_seconds(s).map_or(Value::Null, Value::from), + other => number(other).filter(|n| n.is_finite() && *n > 0.0).map_or(Value::Null, Value::from), + }; + match value { + Value::Array(a) => Value::Array(a.iter().map(secs).collect()), + v => secs(v), + } +} + +/// Seconds in one "in the last…" unit: hours, days, weeks, months or years, in any case, singular +/// or plural; none means days. `None` for anything else, which the check refuses and the matcher +/// matches nothing with. +fn unit_secs(unit: &Value) -> Option { + let day = 86_400.0; + let name = match unit { + Value::Null => return Some(day), + Value::String(s) => s.trim().to_lowercase(), + _ => return None, + }; + match name.strip_suffix('s').unwrap_or(&name) { + "hour" => Some(3600.0), + "day" => Some(day), + "week" => Some(7.0 * day), + "month" => Some(30.4375 * day), + "year" => Some(365.25 * day), + _ => None, + } +} + +/// The longest "in the last…" span: 10,000 years, beyond any capture date ("ever"). +const MAX_LAST_SECS: f64 = 10_000.0 * 365.25 * 86_400.0; + +/// `value` for in-the-last rules: `{n, unit: days|weeks|months|years}` or a number of days, in +/// seconds. `None` for a zero, negative or unreadable `n`; a longer span than [`MAX_LAST_SECS`] +/// is that (so the arithmetic on it can't overflow). fn last_secs(value: &Value) -> Option { let (n, unit) = match value { - Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").and_then(Value::as_str).unwrap_or("days")), - v => (number(v)?, "days"), + Value::Object(o) => (o.get("n").and_then(number)?, o.get("unit").unwrap_or(&Value::Null)), + v => (number(v)?, &Value::Null), }; - let day = 86_400.0; - let per = match unit { - "hours" => 3600.0, - "weeks" => 7.0 * day, - "months" => 30.4375 * day, - "years" => 365.25 * day, - _ => day, - }; - Some((n * per) as i64) + let per = unit_secs(unit)?; + let secs = n * per; + if secs.is_nan() || secs <= 0.0 { + return None; + } + Some(secs.min(MAX_LAST_SECS) as i64) } fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { @@ -226,7 +732,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { return have.is_none_or(str::is_empty); } let Some(h) = have.filter(|h| !h.is_empty()) else { return op == "notInLast" }; - let s = |v: &Value| v.as_str().map(str::trim).unwrap_or("").to_string(); + // the rule's date as the check reads it (a space as the "T"); one it can't read as written + let s = |v: &Value| v.as_str().map(|d| rule_date(d).unwrap_or_else(|_| d.trim().to_string())).unwrap_or_default(); match op { // a prefix: 2026, 2026-04, 2026-04-12 "is" => { @@ -250,7 +757,8 @@ fn date_op(op: &str, have: Option<&str>, value: &Value) -> bool { }, "inLast" | "notInLast" => { let Some(secs) = last_secs(value) else { return false }; - let from = crate::dates::shift_iso(&now(), -secs).unwrap_or_default(); + // a span reaching before year 0 is "ever": every dated photo is in it + let Some(from) = crate::dates::shift_iso(&now(), -secs) else { return op == "inLast" }; (h >= from.as_str()) == (op == "inLast") } _ => false, @@ -266,6 +774,7 @@ impl Rule { let m = &p.meta; let want = lower(value); let text = |have: &str| text_op(op, have, &want); + let yes = |have: bool| bool_value(value).is_some_and(|b| have == b); match field { "rating" => num_op(op, Some(p.rating as f64), value), "flag" => { @@ -279,15 +788,20 @@ impl Rule { (l == want || named) == (op == "is") } "kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"), - "edited" => p.is_edited() == value.as_bool().unwrap_or(true), - "hasGps" => m.gps.is_some() == value.as_bool().unwrap_or(true), - "virtualCopy" => p.copy_of.is_some() == value.as_bool().unwrap_or(true), - "keywords" => match op { - "isEmpty" => m.keywords.is_empty(), - "isNotEmpty" => !m.keywords.is_empty(), - "notContains" => !m.keywords.iter().any(|k| text_op("contains", k, &want)), - _ => m.keywords.iter().any(|k| text_op(op, k, &want) || (op == "is" && k.to_lowercase().split('|').any(|part| part == want))), - }, + "edited" => yes(p.is_edited()), + "hasGps" => yes(m.gps.is_some()), + "virtualCopy" => yes(p.copy_of.is_some()), + "copyName" => text(p.copy_name.as_deref().unwrap_or("")), + "stacked" => yes(cat.stack_of(p.id).is_some()), + "cropped" => yes(p.is_cropped()), + "treatment" => { + let t = if p.develop.treatment == lightcraft_develop::Treatment::Bw { "monochrome" } else { "color" }; + (t == want) == (op == "is") + } + "keywords" => names_op(op, &m.keywords, &want), + "keywordCount" => num_op(op, Some(p.keyword_count() as f64), value), + "person" => names_op(op, &p.people(), &want), + "personCount" => num_op(op, Some(p.people().len() as f64), value), "text" => { let all = [ p.file_name.as_str(), @@ -297,14 +811,20 @@ impl Rule { &m.lens, &m.location, &m.city, + &m.state, &m.country, + &m.alt_text, &p.format, &m.keywords.join(" "), + &p.people().join(" "), + p.copy_name.as_deref().unwrap_or(""), ] .join(" "); text(&all) } "fileName" => text(&p.file_name), + "extension" => text_op(op, p.extension(), want.trim_start_matches('.')), + "duration" => num_op(op, p.duration.filter(|d| d.is_finite()), value), "filePath" => { // `/` and `\` both separate, whatever platform the catalog came from; demo photos have no path let path = match &p.source { @@ -322,6 +842,10 @@ impl Rule { "format" => text(&p.format), "title" => text(&m.title), "caption" => text(&m.caption), + "altText" => text(&m.alt_text), + "city" => text(&m.city), + "state" => text(&m.state), + "country" => text(&m.country), "camera" => text(&m.camera), "lens" => text(&m.lens), "location" => text(&[m.location.as_str(), &m.city, &m.state, &m.country].join(" ")), @@ -334,15 +858,25 @@ impl Rule { "captureDate" => date_op(op, p.captured.as_deref(), value), "importDate" => date_op(op, Some(&p.imported), value), "editDate" => date_op(op, p.edited.as_deref(), value), + "shutterSpeed" => num_op(op, crate::parse_shutter_seconds(&m.shutter), &shutter_value(value)), "iso" => num_op(op, m.iso.map(|v| v as f64), value), "aperture" => num_op(op, m.aperture.map(|v| v as f64), value), "focalLength" => num_op(op, m.focal_mm.map(|v| v as f64), value), - "megapixels" => num_op(op, Some(p.width as f64 * p.height as f64 / 1e6), value), + "longEdge" | "shortEdge" => { + let (w, h) = p.shown_size(); + num_op(op, Some(if field == "longEdge" { w.max(h) } else { w.min(h) }.round()), value) + } + "aspect" => (aspect(p.shown_size()) == Some(want.as_str())) == (op == "is"), + "megapixels" => { + let (w, h) = p.shown_size(); + num_op(op, Some(w * h / 1e6), value) + } "sharpness" => num_op(op, p.analysis.map(|a| a.sharpness as f64), value), - "bestOfGroup" => p.analysis.is_some_and(|a| a.best || a.group.is_none()) == value.as_bool().unwrap_or(true), + "bestOfGroup" => yes(p.analysis.is_some_and(|a| a.best || a.group.is_none())), "album" => { - let id = number(value).map(|v| crate::AlbumId(v as u64)); - id.is_some_and(|a| cat.album(a).is_some_and(|al| !al.is_smart()) && cat.album_contains(a, p)) == (op == "is") + let id = album_rule_id(value); + // a smart album's photos are its matches; one being evaluated (a loop) holds none + id.is_some_and(|a| cat.album_contains(a, p)) == (op == "is") } _ => false, } @@ -370,24 +904,78 @@ impl RuleSet { }) } - /// Unknown fields or operators (for command validation), as readable messages. - pub fn problems(&self) -> Vec { + /// Bring rules saved by older versions up to date, keeping what they meant: an Album rule with + /// an operator from when Album was a number field (≥, between…) always matched as "isn't", and + /// now says so. Commands and the editor apply it before checking. + pub fn upgrade(&mut self) { + for rule in &mut self.rules { + match rule { + Rule::Group { group } => group.upgrade(), + Rule::Field { field, op, .. } if field == "album" && op != "is" && op != "isNot" => *op = "isNot".into(), + Rule::Field { .. } => {} + } + } + } + + /// The rules that can't mean anything, in order: an unknown field, an operator the field + /// doesn't have, a value that isn't one of the field's (a number, a date, a choice, yes or + /// no, an album that exists and isn't a folder…), text with nothing to look for, an empty group. Commands refuse a rule + /// set with problems; the editor shows them. `cat` knows the albums and colour-label names. + pub fn check(&self, cat: &Catalog) -> Vec { + self.check_for(cat, None) + } + + /// [`RuleSet::check`] for the rules of smart album `owner` (`None`: a new album or a filter), + /// which also refuses an Album rule that would make `owner` include itself. + pub fn check_for(&self, cat: &Catalog, owner: Option) -> Vec { let mut out = Vec::new(); - for r in &self.rules { - match r { - Rule::Group { group } => out.extend(group.problems()), - Rule::Field { field, op, .. } => match field_kind(field) { - None => out.push(format!("unknown field `{field}`")), - Some(k) if !ops_for(k).iter().any(|o| o.0 == op) => out.push(format!("`{field}` has no operator `{op}`")), - _ => {} - }, + self.check_into(cat, owner, &mut Vec::new(), &mut out); + out + } + + /// The albums the Album rules test, groups included, in order. + pub fn albums_tested(&self) -> Vec { + let mut out = Vec::new(); + for rule in &self.rules { + match rule { + Rule::Group { group } => out.extend(group.albums_tested()), + Rule::Field { field, value, .. } if field == "album" => out.extend(album_rule_id(value)), + Rule::Field { .. } => {} } } out } + fn check_into(&self, cat: &Catalog, owner: Option, path: &mut Vec, out: &mut Vec) { + for (i, rule) in self.rules.iter().enumerate() { + path.push(i); + match rule { + Rule::Group { group } if group.rules.is_empty() => { + out.push(Problem { path: path.clone(), field: None, issue: Issue::EmptyGroup, message: "an empty group".into() }) + } + Rule::Group { group } => group.check_into(cat, owner, path, out), + Rule::Field { field, op, value } => { + if let Some((issue, message)) = field_problem(field, op, value, cat, owner) { + out.push(Problem { path: path.clone(), field: Some(field.clone()), issue, message }); + } + } + } + path.pop(); + } + } + /// A short readable summary ("rating is ≥ 3 and keywords contains travel"). pub fn describe(&self) -> String { + self.describe_in(None) + } + + /// [`RuleSet::describe`] naming the albums Album rules test ("album isn't “Excluded + /// Photos”"); one that is gone shows as `#9`. + pub fn describe_with(&self, cat: &Catalog) -> String { + self.describe_in(Some(cat)) + } + + fn describe_in(&self, cat: Option<&Catalog>) -> String { let join = match self.mode { Match::All => " and ", Match::Any => " or ", @@ -397,12 +985,16 @@ impl RuleSet { .rules .iter() .map(|r| match r { - Rule::Group { group } => format!("({})", group.describe()), + Rule::Group { group } => format!("({})", group.describe_in(cat)), Rule::Field { field, op, value } => { - let label = FIELDS.iter().find(|f| f.0 == field).map_or(field.as_str(), |f| f.1).to_lowercase(); + let label = field_label(field).unwrap_or(field).to_lowercase(); let op = field_kind(field).and_then(|k| ops_for(k).iter().find(|o| o.0 == op)).map_or(op.as_str(), |o| o.1); let v = match value { - Value::String(s) => s.clone(), + _ if field == "album" && cat.is_some() => album_name(value, cat), + _ if field_kind(field) == Some(Kind::Bool) => { + bool_value(value).map_or_else(|| value.to_string(), |b| bool_label(b).to_lowercase()) + } + Value::String(s) => choice_label(field, s).map_or_else(|| s.clone(), str::to_lowercase), Value::Null => String::new(), Value::Object(o) => format!( "{} {}", @@ -462,14 +1054,14 @@ mod tests { yes(json!({"rules": [{"field": "fileName", "op": "startsWith", "value": "img_"}, {"field": "fileName", "op": "endsWith", "value": ".cr2"}]})); // file path: the whole string (spaces included), either separator, case-insensitive let mut fp = photo(3); - fp.source = Source::File { path: "D:\\Photos\\Aliah Ira Polanco-Grylls\\2026\\IMG_0042.CR2".into() }; + fp.source = Source::File { path: "D:\\Photos\\Lake Como Wedding-Day\\2026\\IMG_0042.CR2".into() }; let fpm = |v: serde_json::Value| rs(v).matches(&fp, &cat); - assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah Ira Polanco-Grylls/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Ira Aliah/"}]}))); - assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Polanco Grylls"}]}))); + assert!(fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake Como Wedding-Day/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Como Lake/"}]}))); + assert!(!fpm(json!({"rules": [{"field": "filePath", "op": "contains", "value": "Wedding Day"}]}))); assert!(fpm(json!({"rules": [{"field": "filePath", "op": "notContains", "value": "/Other/"}]}))); assert!(fpm(json!({"rules": [{"field": "filePath", "op": "startsWith", "value": "d:/photos/"}]}))); - no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Aliah/"}]})); // demo photo has no path + no(json!({"rules": [{"field": "filePath", "op": "contains", "value": "/Lake/"}]})); // demo photo has no path yes(json!({"rules": [{"field": "filePath", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "camera", "op": "contains", "value": "x2"}, {"field": "title", "op": "isEmpty"}]})); yes(json!({"rules": [{"field": "iso", "op": "between", "value": [800, 3200]}, {"field": "aperture", "op": "lte", "value": "f/4"}]})); @@ -504,7 +1096,8 @@ mod tests { #[test] fn problems_and_description() { let r = rs(json!({"rules": [{"field": "rating", "op": "contains", "value": 1}, {"group": {"rules": [{"field": "nope", "op": "is"}]}}]})); - assert_eq!(r.problems(), vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); + let messages: Vec = r.check(&Catalog::new()).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`rating` has no operator `contains`".to_string(), "unknown field `nope`".to_string()]); let r = rs( json!({"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 3}, {"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "weeks"}}]}), ); @@ -514,4 +1107,561 @@ mod tests { assert!(!ops_for(*k).is_empty(), "{f}"); } } + + /// Keyword Count: a photo with 2 keywords is in "≥ 2", "≤ 2" and "is 2"; a hierarchical path + /// (travel|italy|rome) is one keyword, and the same keyword twice in different case is one. + #[test] + fn keyword_count_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // travel|italy|rome, food + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "keywordCount", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "gte", json!(2)) && m(&p, "lte", json!(2)) && m(&p, "is", json!(2))); + assert!(!m(&p, "gte", json!(3)) && !m(&p, "lt", json!(2)) && m(&p, "between", json!([1, 2]))); + p.meta.keywords.push("Food".into()); + assert!(m(&p, "is", json!(2)), "Food and food are one keyword"); + p.meta.keywords.clear(); + assert!(m(&p, "is", json!(0)) && m(&p, "lte", json!(2))); + assert_eq!(field_group("keywordCount"), Some("Keywords & People")); + } + + /// People: the names on face regions. "People contains ana" finds Ana Lima; pets and unnamed + /// faces are not people; People Count counts each person once. + #[test] + fn people_rules() { + use lightcraft_meta::{Rect, Region, RegionKind}; + let cat = Catalog::new(); + let region = |name: Option<&str>, kind: RegionKind| Region { + rect: Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind, + name: name.map(str::to_string), + description: None, + }; + let mut p = photo(1); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "person", "isEmpty", json!(null)) && m(&p, "personCount", "is", json!(0))); + p.meta.regions = vec![ + region(Some("Ana Lima"), RegionKind::Face), + region(Some(" ana lima "), RegionKind::Face), + region(Some("Rex"), RegionKind::Pet), + region(None, RegionKind::Face), + region(Some("Bo"), RegionKind::Face), + ]; + assert!(m(&p, "person", "contains", json!("ana"))); + assert!(m(&p, "person", "is", json!("ANA LIMA"))); + assert!(!m(&p, "person", "is", json!("rex")), "a pet isn't a person"); + assert!(m(&p, "person", "notContains", json!("rex"))); + assert!(m(&p, "person", "isNotEmpty", json!(null))); + assert!(m(&p, "personCount", "is", json!(2)), "Ana Lima once, Bo; not the pet or the unnamed face"); + assert_eq!(field_group("person"), Some("Keywords & People")); + } + + /// Shutter Speed compares exposure times in seconds, written as the camera shows them: a + /// photo at 1/250 is in "≤ 1/60" (faster) and not in "≥ 1/60"; "between 1/1000 and 1/125". + #[test] + fn shutter_speed_rules() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": "shutterSpeed", "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(!m(&p, "gte", json!(0)), "no shutter speed recorded"); + p.meta.shutter = "1/250".into(); + assert!(m(&p, "is", json!("1/250")) && m(&p, "lte", json!("1/60")) && !m(&p, "gte", json!("1/60"))); + assert!(m(&p, "between", json!(["1/1000", "1/125"])) && m(&p, "lt", json!(0.01))); + assert!(!m(&p, "is", json!("1/0")) && !m(&p, "is", json!("fast")), "a value that isn't a time matches nothing"); + p.meta.shutter = "2\"".into(); + assert!(m(&p, "gte", json!("1")) && m(&p, "is", json!(2))); + assert_eq!(field_group("shutterSpeed"), Some("Camera Info")); + } + + #[test] + fn shutter_seconds_parses_camera_notation() { + for (s, want) in [("1/250", Some(0.004)), (" 1/250 s", Some(0.004)), ("0.5", Some(0.5)), ("2\"", Some(2.0)), ("30s", Some(30.0))] { + assert_eq!(crate::parse_shutter_seconds(s), want, "{s}"); + } + for s in ["", "1/0", "0", "-1/250", "fast", "inf", "NaN", "1/x"] { + assert_eq!(crate::parse_shutter_seconds(s), None, "{s}"); + } + } + + /// File Extension ignores case and a leading dot; Copy Name, Alt Text, City, State / + /// Province and Country are text fields of their own (Location still matches all of them). + #[test] + fn file_source_description_and_location_rules() { + let cat = Catalog::new(); + let mut p = photo(1); // IMG_0042.CR2 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "extension", "is", json!("cr2")) && m(&p, "extension", "is", json!(".CR2"))); + assert!(!m(&p, "extension", "is", json!("jpg"))); + p.file_name = "README".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "no dot, no extension"); + p.file_name = "archive.tar.gz".into(); + assert!(m(&p, "extension", "is", json!("gz"))); + p.file_name = ".hidden".into(); + assert!(m(&p, "extension", "isEmpty", json!(null)), "a dot file has no extension"); + assert!(m(&p, "copyName", "isEmpty", json!(null))); + p.copy_name = Some("Black and white".into()); + assert!(m(&p, "copyName", "contains", json!("black"))); + p.meta.alt_text = "A red kite over a hill".into(); + assert!(m(&p, "altText", "contains", json!("kite"))); + p.meta.city = "Porto".into(); + p.meta.state = "Porto District".into(); + p.meta.country = "Portugal".into(); + assert!(m(&p, "city", "is", json!("porto")) && m(&p, "state", "startsWith", json!("porto")) && m(&p, "country", "is", json!("portugal"))); + assert!(!m(&p, "city", "is", json!("portugal")), "City tests only the city"); + assert!(m(&p, "location", "contains", json!("portugal")), "Location still covers the country"); + } + + /// In a Stack, Video Duration (seconds; photos have none), Cropped and Treatment. + #[test] + fn source_file_and_develop_rules() { + use crate::{Op, Stack, StackId}; + let mut cat = Catalog::new(); + let a = photo(1); + let b = photo(2); + let c = photo(3); + for p in [&a, &b, &c] { + cat.apply(Op::AddPhoto { photo: Box::new(p.clone()) }).unwrap(); + } + cat.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![a.id, b.id], collapsed: false } }).unwrap(); + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&a, "stacked", "is", json!(true)) && m(&b, "stacked", "is", json!(true))); + assert!(m(&c, "stacked", "is", json!(false))); + let mut v = photo(4); + assert!(!m(&v, "duration", "gte", json!(0)), "a photo has no duration"); + v.duration = Some(95.0); + assert!(m(&v, "duration", "gt", json!(60)) && m(&v, "duration", "between", json!([90, 120]))); + let mut d = photo(5); + assert!(m(&d, "cropped", "is", json!(false)) && m(&d, "treatment", "is", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9, 1.0); + s.treatment = lightcraft_develop::Treatment::Bw; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)) && m(&d, "treatment", "is", json!("monochrome"))); + assert!(m(&d, "treatment", "isNot", json!("color"))); + let mut s = (*d.develop).clone(); + s.crop = Default::default(); + s.crop.geometry.angle = 2.0; + d.develop = std::sync::Arc::new(s); + assert!(m(&d, "cropped", "is", json!(true)), "straightening counts as a crop"); + } + + /// Size follows the photo as shown: rotated a quarter turn a landscape frame is a portrait, and + /// a crop changes its edges and its megapixels. Long / Short Edge are in pixels. + #[test] + fn size_rules_follow_orientation_and_crop() { + let cat = Catalog::new(); + let mut p = photo(1); // 6000 × 4000 + let m = |p: &Photo, field: &str, op: &str, value: serde_json::Value| { + rs(json!({"rules": [{"field": field, "op": op, "value": value}]})).matches(p, &cat) + }; + assert!(m(&p, "aspect", "is", json!("landscape")) && m(&p, "longEdge", "is", json!(6000)) && m(&p, "shortEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(24))); + let mut s = (*p.develop).clone(); + s.orientation = lightcraft_geom::Orientation::Rotate90; + p.develop = std::sync::Arc::new(s.clone()); + assert!(m(&p, "aspect", "is", json!("portrait")) && m(&p, "longEdge", "is", json!(6000))); + // a square crop of the rotated frame: 4000 wide, 4000 of its 6000 tall + s.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 1.0 / 6.0, 1.0, 5.0 / 6.0); + p.develop = std::sync::Arc::new(s); + assert!(m(&p, "aspect", "is", json!("square")) && m(&p, "longEdge", "is", json!(4000))); + assert!(m(&p, "megapixels", "is", json!(16)), "a 4000 × 4000 crop is 16 MP, not the sensor's 24"); + assert!(m(&p, "megapixels", "lt", json!(20)) && !m(&p, "megapixels", "gte", json!(24))); + assert!(m(&p, "aspect", "isNot", json!("portrait"))); + let empty = Photo::new(PhotoId(9), Source::Demo { scene: 0 }, "x.jpg", "JPEG", 0, 0, "2026-09-20T10:00:00"); + assert!( + !m(&empty, "aspect", "is", json!("landscape")) && !m(&empty, "aspect", "is", json!("square")) && m(&empty, "longEdge", "is", json!(0)) + ); + assert!(m(&empty, "megapixels", "is", json!(0))); + } + + /// Any Searchable Text also finds the state / province, the alt text, a person on a face and + /// a virtual copy's name, as it already finds the city and country. + #[test] + fn any_searchable_text_covers_new_fields() { + let cat = Catalog::new(); + let mut p = photo(1); + let m = |p: &Photo, value: &str| rs(json!({"rules": [{"field": "text", "op": "contains", "value": value}]})).matches(p, &cat); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(!m(&p, word), "{word}"); + } + p.meta.state = "Oregon".into(); + p.meta.alt_text = "A kite".into(); + p.meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_meta::Rect { x0: 0.4, y0: 0.4, x1: 0.6, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some("Ana".into()), + description: None, + }]; + p.copy_name = Some("Bluish".into()); + for word in ["oregon", "kite", "ana", "bluish"] { + assert!(m(&p, word), "{word}"); + } + } + + /// Choice values have readable labels: the editor and summaries show "Public Domain" and + /// "Black & White", never ids like `publicDomain`; rules still store and accept the ids. + #[test] + fn choices_have_readable_labels() { + assert_eq!(choice_label("copyrightStatus", "publicDomain"), Some("Public Domain")); + assert_eq!(choice_label("treatment", "monochrome"), Some("Black & White")); + // own labels where a shared word would translate wrongly: "Color" is the colour noun (the + // Color panel) and "None" agrees with other nouns in gendered languages + assert_eq!(choice_label("treatment", "color"), Some("In Color")); + assert_eq!(choice_label("label", "none"), Some("No Label")); + assert_eq!(choice_label("flag", "pick"), Some("Picked")); + assert_eq!(choice_label("aspect", "landscape"), Some("Landscape (wide)")); + assert_eq!(choice_label("flag", "nope"), None); + assert_eq!(choice_label("rating", "pick"), None, "not a choice field"); + for (field, _, kind) in FIELDS { + let Kind::Choice(choices) = kind else { continue }; + for (id, label) in *choices { + assert!(!label.is_empty() && label != id, "{field}: {id} has no label of its own"); + assert!(label.starts_with(|c: char| c.is_uppercase()), "{field}: {label}"); + } + } + let r = rs( + json!({"rules": [{"field": "copyrightStatus", "op": "is", "value": "publicDomain"}, {"field": "flag", "op": "isNot", "value": "reject"}]}), + ); + assert_eq!(r.describe(), "copyright status is public domain and pick flag isn't rejected"); + let mut p = photo(1); + p.meta.copyright_status = crate::CopyrightStatus::PublicDomain; + assert!(r.matches(&p, &Catalog::new()), "ids still match"); + } + + /// Yes/no fields read "Yes" / "No", and a value means the same to matching and to every + /// summary: `false`, `"false"`, `"no"` and `0` are no; `true`, `"yes"`, `1` and a missing value + /// are yes. Anything else is reported, not silently taken as yes. + #[test] + fn yes_no_values() { + for v in [json!(true), json!("true"), json!("Yes"), json!(1), json!(1.0), json!(null)] { + assert_eq!(bool_value(&v), Some(true), "{v}"); + } + for v in [json!(false), json!("false"), json!(" NO "), json!(0), json!(0.0)] { + assert_eq!(bool_value(&v), Some(false), "{v}"); + } + for v in [json!("maybe"), json!(2), json!(0.5), json!(-1), json!([true]), json!({"x": 1})] { + assert_eq!(bool_value(&v), None, "{v}"); + } + assert_eq!((bool_label(true), bool_label(false)), ("Yes", "No")); + let cat = Catalog::new(); + let p = photo(1); // unedited + assert!(rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}]})).matches(&p, &cat), "\"false\" means no"); + assert!( + !rs(json!({"rules": [{"field": "edited", "op": "is", "value": "false"}, {"field": "hasGps", "op": "is", "value": "yes"}]})) + .matches(&p, &cat) + ); + assert!(!rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})).matches(&p, &cat), "an unreadable value matches nothing"); + let r = rs(json!({"rules": [{"field": "edited", "op": "is", "value": true}, {"field": "cropped", "op": "is", "value": "false"}]})); + assert_eq!(r.describe(), "has edits is yes and cropped is no"); + let bad = rs(json!({"rules": [{"field": "edited", "op": "is", "value": "maybe"}]})); + let messages: Vec = bad.check(&cat).into_iter().map(|p| p.message).collect(); + assert_eq!(messages, vec!["`edited` is yes or no, not \"maybe\"".to_string()]); + } + + /// "In the last N" never overflows, whatever N an agent or a saved file holds: a zero, + /// negative or unreadable N matches nothing (either way round); an N beyond 10,000 years means + /// "ever", so every dated photo is in it and none is outside it. + #[test] + fn in_the_last_survives_hostile_counts() { + set_now(Some("2026-09-01T00:00:00".into())); + let cat = Catalog::new(); + let p = photo(1); // captured 2026-08-14 + let m = |op: &str, n: serde_json::Value, unit: &str| { + rs(json!({"rules": [{"field": "captureDate", "op": op, "value": {"n": n, "unit": unit}}]})).matches(&p, &cat) + }; + for n in [json!(0), json!(-5), json!(-1e30), json!(-9.3e18), json!("soon")] { + for op in ["inLast", "notInLast"] { + assert!(!m(op, n.clone(), "years"), "{op} {n}"); + } + } + for n in [json!(1e30), json!(9.3e18), json!(u64::MAX), json!(20_000)] { + assert!(m("inLast", n.clone(), "years"), "{n} years is ever"); + assert!(!m("notInLast", n.clone(), "years"), "{n}"); + } + assert!(m("inLast", json!(1e30), "hours") && m("inLast", json!(30), "days")); + set_now(None); + } + + /// Every rule's value is checked against its field, so a rule that can't mean anything is + /// reported instead of quietly matching nothing (or everything). Each problem says which rule + /// it is about: "rule 2", or "rule 2.1" inside a group. + #[test] + fn values_are_checked_against_their_field() { + use crate::{Album, AlbumId, Op}; + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); + let smart = Album { smart: Some(Box::default()), ..Album::new(AlbumId(2), "Best") }; + cat.apply(Op::AddAlbum { album: smart }).unwrap(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Client".into()) }).unwrap(); + let check = |rule: serde_json::Value| rs(json!({"rules": [rule]})).check(&cat); + let ok = |rule: serde_json::Value| assert!(check(rule.clone()).is_empty(), "{rule}: {:?}", check(rule.clone())); + let bad = |rule: serde_json::Value, want: &str| { + let p = check(rule.clone()); + assert!(p.iter().any(|p| p.message.contains(want)), "{rule}: wanted {want:?}, got {p:?}"); + }; + let r = |field: &str, op: &str, value: serde_json::Value| json!({"field": field, "op": op, "value": value}); + // numbers + ok(r("rating", "gte", json!(3))); + ok(r("rating", "gte", json!("4"))); + ok(r("rating", "between", json!([1, 5]))); + ok(r("aperture", "lte", json!("f/4"))); + ok(r("focalLength", "is", json!("50mm"))); + bad(r("rating", "gte", json!("abc")), "needs a number"); + bad(r("rating", "gte", json!(7)), "no rating 0–5 is ≥ 7"); + bad(r("iso", "between", json!([100])), "two"); + bad(r("iso", "between", json!("100")), "two"); + ok(r("shutterSpeed", "lte", json!("1/60"))); + bad(r("shutterSpeed", "lte", json!("fast")), "1/250"); + // dates + for d in ["2026", "2026-08", "2026-08-14", "2026-08-14T10:00:00"] { + ok(r("captureDate", "is", json!(d))); + } + ok(r("captureDate", "between", json!(["2026-01", "2026-12"]))); + for d in [json!("banana"), json!("2026-13"), json!("2026-02-30"), json!(""), json!(2026)] { + bad(r("captureDate", "after", d), "needs a date"); + } + bad(r("captureDate", "between", json!(["2026-12", "2026-01"])), "after the second"); + bad(r("captureDate", "between", json!(["", "2026"])), "needs a date"); + ok(r("captureDate", "inLast", json!({"n": 2, "unit": "weeks"}))); + ok(r("captureDate", "inLast", json!(7))); + bad(r("captureDate", "inLast", json!({"n": 0, "unit": "days"})), "more than 0"); + bad(r("captureDate", "notInLast", json!({"n": 3, "unit": "fortnights"})), "fortnights"); + ok(r("captureDate", "isEmpty", json!(null))); + // choices: the ids, the flag's aliases and a colour label's custom name + ok(r("kind", "is", json!("raw"))); + ok(r("flag", "is", json!("picked"))); + ok(r("label", "is", json!("client"))); + bad(r("label", "is", json!("pink")), "pink"); + bad(r("kind", "isNot", json!("photo")), "photo"); + // text needs something to look for + ok(r("title", "contains", json!("kite"))); + ok(r("title", "isEmpty", json!(null))); + bad(r("title", "contains", json!(" ")), "something to look for"); + bad(r("keywords", "startsWith", json!("")), "something to look for"); + // albums: one that exists, plain or smart + ok(r("album", "is", json!(1))); + bad(r("album", "is", json!(999)), "no album 999"); + ok(r("album", "is", json!(2))); + // groups: an empty one is a mistake; problems inside one say where they are + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"match": "any", "rules": []}}]})).check(&cat); + assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 2: an empty group".to_string()]); + let p = rs(json!({"rules": [r("rating", "gte", json!(3)), {"group": {"rules": [r("iso", "is", json!(100)), r("rating", "is", json!(9))]}}]})) + .check(&cat); + assert_eq!(p.len(), 1); + assert_eq!(p[0].path, vec![1, 1]); + assert_eq!(p[0].to_string(), "rule 2.2: no rating 0–5 is 9"); + // an empty rule list at the top is "all photos" (or none, for any), not a mistake + assert!(rs(json!({"rules": []})).check(&cat).is_empty()); + } + + /// The check and the matcher agree. Units are any case, singular or plural, and missing means + /// days; an unknown unit is refused and matches nothing (it used to count as days, so "week" was + /// one day). A rating rule is refused only when no rating 0–5 could meet it. A colour label's + /// custom name matches in any case, accents included. Empty text hints at the right operator. + #[test] + fn check_and_matcher_agree() { + use crate::Op; + set_now(Some("2026-09-01T00:00:00".into())); + let mut cat = Catalog::new(); + cat.apply(Op::SetLabelName { label: crate::ColorLabel::Red, name: Some("Été".into()) }).unwrap(); + let p = photo(1); // captured 2026-08-14 (18 days before now), red label, rating 4 + let rule = |field: &str, op: &str, value: serde_json::Value| rs(json!({"rules": [{"field": field, "op": op, "value": value}]})); + let valid = |r: &RuleSet| r.check(&cat).is_empty(); + for unit in [json!("Days"), json!("DAY"), json!("days"), json!(null)] { + let r = rule("captureDate", "inLast", json!({"n": 30, "unit": unit})); + assert!(valid(&r) && r.matches(&p, &cat), "{unit}"); + } + let r = rule("captureDate", "inLast", json!({"n": 30})); + assert!(valid(&r) && r.matches(&p, &cat), "no unit is days"); + let week = rule("captureDate", "inLast", json!({"n": 1, "unit": "week"})); + assert!(valid(&week) && !week.matches(&p, &cat), "a week, not a day: 18 days ago is outside it"); + assert!(rule("captureDate", "inLast", json!({"n": 3, "unit": "Weeks"})).matches(&p, &cat)); + let odd = rule("captureDate", "inLast", json!({"n": 30, "unit": "fortnights"})); + assert!(!valid(&odd) && !odd.matches(&p, &cat), "an unknown unit is refused and matches nothing"); + // rating: only a rule no rating could meet + for (op, v) in [("lt", json!(6)), ("gt", json!(-1)), ("isNot", json!(9)), ("between", json!([3, 8]))] { + assert!(valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + for (op, v) in [("is", json!(9)), ("gte", json!(7)), ("gt", json!(5)), ("lt", json!(0)), ("between", json!([6, 9]))] { + assert!(!valid(&rule("rating", op, v.clone())), "{op} {v}"); + } + // a custom label name, any case, accents too + let r = rule("label", "is", json!("été")); + assert!(valid(&r) && r.matches(&p, &cat)); + // "isn't" with nothing points at "isn't empty", the others at "is empty" + let hint = |op: &str| rule("title", op, json!("")).check(&cat).first().map(|p| p.message.clone()).unwrap_or_default(); + assert!(hint("isNot").contains("isn't empty"), "{}", hint("isNot")); + assert!(hint("contains").contains("“is empty”"), "{}", hint("contains")); + set_now(None); + } + + /// An album is in it or not: Album offers "is" and "isn't", never "≥" or "between". + #[test] + fn album_rules_are_is_or_isnt() { + assert_eq!(field_kind("album"), Some(Kind::Album)); + let ops: Vec<&str> = ops_for(Kind::Album).iter().map(|o| o.0).collect(); + assert_eq!(ops, ["is", "isNot"]); + let r = rs(json!({"rules": [{"field": "album", "op": "gte", "value": 1}]})); + assert_eq!(r.check(&Catalog::new()).first().map(|p| p.message.as_str()), Some("`album` has no operator `gte`")); + } + + /// An Album rule names its album by id, written as a number or a numeric string, the same way + /// for the check, the matcher and the editor. A folder holds albums, not photos, so it can't be + /// tested. + #[test] + fn album_rule_ids() { + use crate::{Album, AlbumId, Op}; + assert_eq!(album_rule_id(&json!(3)), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!("3")), Some(AlbumId(3))); + assert_eq!(album_rule_id(&json!(3.0)), Some(AlbumId(3))); + for v in [json!(1.5), json!(-1), json!("x"), json!(null)] { + assert_eq!(album_rule_id(&v), None, "{v}"); + } + let mut cat = Catalog::new(); + cat.apply(Op::AddAlbum { album: Album { folder: true, ..Album::new(AlbumId(4), "Trips") } }).unwrap(); + let p = rs(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})).check(&cat); + assert!(p.first().is_some_and(|p| p.message.contains("folder")), "{p:?}"); + } + + /// Album rules saved before Album offered only "is" / "isn't" (≥, between… from when it was a + /// number field) always meant "isn't" to the matcher; upgrading says so, so they check again. + #[test] + fn old_album_operators_upgrade_to_isnt() { + let mut r = rs(json!({"rules": [ + {"field": "album", "op": "gte", "value": 1}, + {"field": "album", "op": "is", "value": 1}, + {"field": "rating", "op": "gte", "value": 3}, + {"group": {"rules": [{"field": "album", "op": "between", "value": [1, 2]}]}} + ]})); + r.upgrade(); + let ops = serde_json::to_value(&r).unwrap().to_string(); + assert!(ops.contains(r#""field":"album","op":"isNot","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"album","op":"is","value":1"#), "{ops}"); + assert!(ops.contains(r#""field":"rating","op":"gte""#), "other fields keep theirs: {ops}"); + assert!(ops.contains(r#""field":"album","op":"isNot","value":[1,2]"#), "inside groups too: {ops}"); + } + + /// Besides its English message (for agents), a problem names its field and the kind of issue, + /// so the editor can show "Title: needs something to look for" in any language. Every issue + /// has its own short text. + #[test] + fn problems_name_their_field_and_issue() { + let cat = Catalog::new(); + let check = |rules: serde_json::Value| rs(json!({"rules": rules})).check(&cat); + let p = check(json!([{"field": "title", "op": "contains", "value": ""}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("title"), Issue::NeedsTextOrEmpty)); + let p = check(json!([{"field": "title", "op": "isNot", "value": ""}])); + assert_eq!(p[0].issue, Issue::NeedsTextOrNotEmpty); + // keywords and people say "are empty", and so does their hint + let p = check(json!([{"field": "keywords", "op": "contains", "value": ""}, {"field": "person", "op": "notContains", "value": " "}])); + assert_eq!((p[0].issue, p[1].issue), (Issue::NeedsNamesOrEmpty, Issue::NeedsNamesOrNotEmpty)); + assert!(p[0].issue.text().contains("“are empty”") && p[0].message.contains("“are empty”")); + let p = check(json!([{"field": "rating", "op": "is", "value": 9}, {"group": {"rules": []}}])); + assert_eq!((p[0].field.as_deref(), p[0].issue), (Some("rating"), Issue::NoRatingMatches)); + assert_eq!((p[1].field.as_deref(), p[1].issue), (None, Issue::EmptyGroup)); + let p = check(json!([{"field": "captureDate", "op": "between", "value": ["2026-12", "2026-01"]}])); + assert_eq!(p[0].issue, Issue::DatesReversed); + let p = check(json!([{"field": "album", "op": "is", "value": null}])); + assert_eq!(p[0].issue, Issue::ChooseAlbum); + let mut texts: Vec<&str> = Issue::ALL.iter().map(|i| i.text()).collect(); + assert!(texts.iter().all(|t| !t.is_empty())); + texts.sort_unstable(); + texts.dedup(); + assert_eq!(texts.len(), Issue::ALL.len(), "each issue reads differently"); + } + + /// A rule date reads one way for the check and the matcher: a space works as the "T" + /// ("2026-10-01 10:00" matches a photo taken then). Imported capture times can carry fractions + /// of a second and a time zone (EXIF SubSecTime / OffsetTime: "…T10:00:00.250+02:00"), so a + /// rule may too, after the seconds; anywhere else they are no date. + #[test] + fn rule_dates_have_one_reading() { + let cat = Catalog::new(); + let mut p = photo(1); + p.captured = Some("2026-10-01T10:00:00".into()); + let rule = |op: &str, v: serde_json::Value| rs(json!({"rules": [{"field": "captureDate", "op": op, "value": v}]})); + for (op, v) in [ + ("is", json!("2026-10-01 10:00")), + ("is", json!(" 2026-10-01T10 ")), + ("between", json!(["2026-10-01 09:00", "2026-10-01 11:00"])), + ("after", json!("2026-10-01 09:59")), + ] { + let r = rule(op, v.clone()); + assert!(r.check(&cat).is_empty() && r.matches(&p, &cat), "{op} {v}"); + } + // a capture time with a fraction and a zone, as imports write it + let mut zoned = photo(2); + zoned.captured = Some("2026-08-14T10:00:00.250+02:00".into()); + for v in ["2026-08-14T10:00:00.250", "2026-08-14T10:00:00.250+02:00", "2026-08-14T10:00:00"] { + let r = rule("is", json!(v)); + assert!(r.check(&cat).is_empty() && r.matches(&zoned, &cat), "{v}"); + } + assert!(rule("is", json!("2026-08-14T10:00:00Z")).check(&cat).is_empty()); + for v in [ + "2026-8-14", + "2026-08-14T1", + "2026-08-14X10", + "26-08-14", + "2026-08-14T25:00", + "2026-08-14Z", + "2026-08-14 10:00-05:00", + "2026-08-14T10:00:00.", + "2026-08-14T10:00:00+2", + ] { + let p = rule("is", json!(v)).check(&cat); + assert_eq!(p.first().map(|p| p.issue), Some(Issue::NeedsDate), "{v}"); + } + assert_eq!(rule_date("2026-10-01 10:00"), Ok("2026-10-01T10:00".to_string())); + assert_eq!(rule_date("2026"), Ok("2026".to_string())); + } + + /// The field menu shows every rule field once: at the top level or in exactly one group, + /// and [`FIELDS`] lists them in menu order. + #[test] + fn every_field_is_in_the_menu_exactly_once() { + let menu: Vec<&str> = TOP_LEVEL_FIELDS.iter().chain(FIELD_GROUPS.iter().flat_map(|g| g.1.iter())).copied().collect(); + let fields: Vec<&str> = FIELDS.iter().map(|f| f.0).collect(); + assert_eq!(menu, fields, "the menu and FIELDS list the same fields in the same order"); + for (label, group) in FIELD_GROUPS { + assert!(!group.is_empty(), "group {label} is empty"); + } + let mut labels: Vec<&str> = FIELD_GROUPS.iter().map(|g| g.0).collect(); + labels.dedup(); + assert_eq!(labels.len(), FIELD_GROUPS.len(), "group labels are unique"); + } + + /// Related fields sit together: all the file fields, all the camera (EXIF) fields, all the + /// dates, all the keyword fields. + #[test] + fn related_fields_share_a_group() { + let same = |fields: &[&str], group: &str| { + for f in fields { + assert_eq!(field_group(f), Some(group), "{f}"); + } + }; + same(&["fileName", "extension", "filePath", "kind", "format", "duration"], "File"); + same(&["album", "virtualCopy", "copyName", "stacked"], "Source"); + same(&["longEdge", "shortEdge", "aspect", "megapixels"], "Size"); + same(&["edited", "cropped", "treatment"], "Develop"); + same(&["camera", "lens", "focalLength", "aperture", "shutterSpeed", "iso"], "Camera Info"); + same(&["captureDate", "importDate", "editDate"], "Date"); + same(&["keywords", "keywordCount", "person", "personCount"], "Keywords & People"); + same(&["title", "caption", "altText", "creator", "copyright", "copyrightStatus"], "Description"); + same(&["location", "city", "state", "country", "hasGps"], "Location"); + for f in ["rating", "flag", "label", "text"] { + assert_eq!(field_group(f), None, "{f} stays at the top level"); + } + assert_eq!(field_group("nope"), None); + assert_eq!(field_label("filePath"), Some("File Path")); + assert_eq!(field_label("nope"), None); + } } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 4c2bb33eb..20f4b7282 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -457,6 +457,178 @@ fn random_sort_has_no_date_headers() { assert!(c.date_runs(&ids, SortKey::Random, GroupBy::Day).is_empty()); } +/// Reload stores the lens data a file carries; the inverse (through the journal's JSON) takes it back. +#[test] +fn set_embedded_lens_is_undoable_and_journaled() { + let mut c = Catalog::default(); + let a = c.alloc_photo_id(); + c.apply(Op::AddPhoto { + photo: Box::new(Photo::new(a, Source::File { path: "/x.rw2".into() }, "x.rw2", "RW2", 4000, 3000, "2026-10-06T00:00:00")), + }) + .unwrap(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), vignette: None }; + let op = Op::SetEmbeddedLens { id: a, lens: Some(Box::new(lens)) }; + let op: Op = serde_json::from_str(&serde_json::to_string(&op).unwrap()).unwrap(); + let inv = c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().embedded_lens, Some(lens)); + let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); + c.apply(inv).unwrap(); + assert_eq!(c.photo(a).unwrap().embedded_lens, None); +} + +/// A saved smart album whose rules no longer check (the album a rule tests was deleted) is found, +/// with its problems; a good one has none; an old album operator is read as it was meant. +#[test] +fn smart_album_problems_flag_stale_rules() { + let mut c = Catalog::new(); + c.apply(Op::AddAlbum { album: Album::new(AlbumId(1), "Trip") }).unwrap(); + let smart = |id: u64, rules: serde_json::Value| { + let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": rules}})).unwrap(); + Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "Smart") } + }; + c.apply(Op::AddAlbum { album: smart(2, serde_json::json!([{"field": "album", "op": "is", "value": 1}])) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(3, serde_json::json!([{"field": "album", "op": "gte", "value": 1}])) }).unwrap(); + assert!(c.smart_album_problems(AlbumId(2)).is_empty()); + assert!(c.smart_album_problems(AlbumId(3)).is_empty(), "an old operator is upgraded, not a problem"); + assert!(c.smart_album_problems(AlbumId(1)).is_empty(), "a plain album has no rules"); + c.apply(Op::RemoveAlbum { id: AlbumId(1) }).unwrap(); + let p = c.smart_album_problems(AlbumId(2)); + assert_eq!(p.iter().map(ToString::to_string).collect::>(), vec!["rule 1: no album 1".to_string()]); +} + +/// A smart album can test another smart album: "Keywords contain travel" and "Album isn't Excluded +/// Photos" (red or rejected) leaves out the excluded photos, and follows that album's rules as they +/// change. +#[test] +fn a_smart_album_can_exclude_another() { + let mut c = Catalog::new(); + let smart = |id: u64, name: &str, rules: serde_json::Value| { + let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": rules})).unwrap(); + Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), name) } + }; + c.apply(Op::AddAlbum { + album: smart( + 1, + "Excluded Photos", + serde_json::json!({"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}), + ), + }) + .unwrap(); + let travel = |c: &mut Catalog, name: &str, label: Option, flag: Flag| { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "JPEG", 6000, 4000, "2026-09-30T10:00:00"); + p.meta.keywords = vec!["travel".into()]; + p.label = label; + p.flag = flag; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + let keep = travel(&mut c, "keep.jpg", None, Flag::Pick); + let red = travel(&mut c, "red.jpg", Some(ColorLabel::Red), Flag::None); + let rejected = travel(&mut c, "rejected.jpg", None, Flag::Reject); + let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [ + {"field": "keywords", "op": "contains", "value": "travel"}, {"field": "album", "op": "isNot", "value": 1}]})) + .unwrap(); + assert!(rules.check(&c).is_empty(), "a smart album can be tested: {:?}", rules.check(&c)); + let matched = |c: &Catalog| c.photos().filter(|p| rules.matches(p, c)).map(|p| p.id).collect::>(); + assert_eq!(matched(&c), vec![keep]); + // "is" works the other way round + let inside: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 1}]})).unwrap(); + assert_eq!(c.photos().filter(|p| inside.matches(p, &c)).count(), 2); + let _ = (red, rejected); +} + +/// An album can't include itself, directly or through other smart albums: the check refuses the +/// loop for the album being edited, and a loop saved anyway (an older version, a hand-edited file) +/// neither recurses forever nor crashes: inside it, the album being evaluated counts as holding no +/// photos, and every album in it is reported. +#[test] +fn smart_album_loops_are_refused_and_survived() { + let mut c = Catalog::new(); + let refers = |to: u64| -> Filter { + serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": to}]}})).unwrap() + }; + let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") }; + // A → B → C + c.apply(Op::AddAlbum { album: smart(3, Filter::default()) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(2, refers(3)) }).unwrap(); + c.apply(Op::AddAlbum { album: smart(1, refers(2)) }).unwrap(); + let rules = |to: u64| refers(to).rule_set.unwrap(); + assert!(rules(1).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "C → A closes A → B → C → A"); + assert!(rules(3).check_for(&c, Some(AlbumId(3))).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "an album testing itself"); + assert!(rules(1).check_for(&c, None).is_empty(), "a new album can't be in a loop yet"); + assert!(rules(3).check_for(&c, Some(AlbumId(1))).is_empty(), "A testing C again is no loop"); + assert!(!c.album_reaches(AlbumId(3), AlbumId(1)) && c.album_reaches(AlbumId(1), AlbumId(3))); + // a loop saved anyway: C → A + c.apply(Op::SetAlbumRules { id: AlbumId(3), rules: Box::new(refers(1)) }).unwrap(); + let id = photo(&mut c, "a.jpg", "2026-09-01T10:00:00"); + let p = c.photo(id).unwrap().clone(); + for a in [1, 2, 3] { + let _ = c.album_contains(AlbumId(a), &p); // returns, no stack overflow + assert!(c.smart_album_problems(AlbumId(a)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop), "album {a} is reported"); + } +} + +/// Summaries name the albums Album rules test ("album isn't “Excluded Photos”"), not their ids; +/// one that is gone shows as its number. +#[test] +fn summaries_name_albums() { + let mut c = Catalog::new(); + c.apply(Op::AddAlbum { album: Album::new(AlbumId(4), "Excluded Photos") }).unwrap(); + let rules: RuleSet = serde_json::from_value(serde_json::json!({"rules": [ + {"field": "keywords", "op": "contains", "value": "travel"}, + {"group": {"match": "any", "rules": [{"field": "album", "op": "isNot", "value": 4}, {"field": "album", "op": "is", "value": 9}]}}]})) + .unwrap(); + assert_eq!(rules.describe_with(&c), "keywords contains travel and (album isn't “Excluded Photos” or album is #9)"); + let f = Filter { rule_set: Some(rules), ..Default::default() }; + assert!(f.describe_with(&c).contains("album isn't “Excluded Photos”")); +} + +/// A chain of smart albums each testing the one before twice ("all of: album is X(k-1), album is +/// X(k-1)") costs a pass per album, not 2^depth: each album's answer for a photo is worked out once +/// per question. 25 albums deep stays instant. +#[test] +fn chains_of_smart_albums_stay_linear() { + let mut c = Catalog::new(); + let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "X") }; + c.apply(Op::AddAlbum { album: smart(1, Filter::default()) }).unwrap(); + for k in 2..=26u64 { + let f: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [ + {"field": "album", "op": "is", "value": k - 1}, {"field": "album", "op": "is", "value": k - 1}]}})) + .unwrap(); + c.apply(Op::AddAlbum { album: smart(k, f) }).unwrap(); + } + for i in 0..20 { + photo(&mut c, &format!("p{i}.jpg"), "2026-09-01T10:00:00"); + } + let start = std::time::Instant::now(); + assert_eq!(c.album_count(AlbumId(26)), 20); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "took {:?}", start.elapsed()); +} + +/// A smart album's own album filter (not only its rules) counts in loops: A filtered to "in A" is +/// reported, and so is A filtered to B while B's rules test A. +#[test] +fn an_album_filter_counts_in_loops() { + let mut c = Catalog::new(); + let smart = |id: u64, f: Filter| Album { smart: Some(Box::new(f)), ..Album::new(AlbumId(id), "S") }; + c.apply(Op::AddAlbum { album: smart(1, Filter { album: Some(AlbumId(1)), ..Default::default() }) }).unwrap(); + let loops = |c: &Catalog, id: u64| c.smart_album_problems(AlbumId(id)).iter().any(|p| p.issue == crate::rules::Issue::AlbumLoop); + assert!(loops(&c, 1), "filtered to itself"); + let tests_3: Filter = serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 3}]}})).unwrap(); + c.apply(Op::AddAlbum { album: smart(2, Filter { album: Some(AlbumId(3)), ..Default::default() }) }).unwrap(); + c.apply(Op::AddAlbum { + album: smart(3, serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": 2}]}})).unwrap()), + }) + .unwrap(); + assert!(c.album_reaches(AlbumId(3), AlbumId(2)) && c.album_reaches(AlbumId(2), AlbumId(3))); + assert!(loops(&c, 2) && loops(&c, 3)); + let _ = tests_3; + let shown: Vec = c.smart_album_problems(AlbumId(1)).iter().map(ToString::to_string).collect(); + assert!(shown.iter().all(|s| !s.starts_with("rule :")), "{shown:?}"); +} + #[test] fn undated_photos_group_under_unknown_date_and_sort_together() { let mut c = Catalog::new(); diff --git a/crates/catalog/src/tests_folder_records.rs b/crates/catalog/src/tests_folder_records.rs new file mode 100644 index 000000000..3ae19c33a --- /dev/null +++ b/crates/catalog/src/tests_folder_records.rs @@ -0,0 +1,199 @@ +//! What the library keeps about its folders: a colour label, like a photo's (see +//! [`crate::folders::FolderRecord`]). +//! +//! Scenarios, in the words of someone organising their library by folder: +//! +//! * Given a folder of the library, when I give it a colour label, its row in the folder tree +//! shows that label; the folders around it show none. +//! * However the folder is spelled, it is one folder with one label. +//! * When I take the label off, the library keeps nothing about the folder. +//! * Every change is one undo step: undo puts back the label the folder had before. +//! * A path that names no folder is refused and nothing changes. +//! * When the folder is renamed or moved, its label and those of the folders inside it go along; +//! a folder beside it whose name merely starts the same way keeps its own. Undo brings them +//! back, also a label that was left at the destination. +//! * Labels survive closing and reopening the library. + +use crate::*; + +fn add(c: &mut Catalog, path: &str) { + let id = c.alloc_photo_id(); + let name = path.rsplit('/').next().unwrap_or(path); + let p = Photo::new(id, Source::File { path: path.into() }, name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); +} + +fn library() -> Catalog { + let mut c = Catalog::new(); + for p in ["/pics/trip/a.jpg", "/pics/trip/day1/b.jpg", "/pics/trip2/c.jpg", "/pics/home/d.jpg"] { + add(&mut c, p); + } + c +} + +fn label(c: &mut Catalog, path: &str, l: Option) -> Op { + let op = c.folder_label_op(path, l); + c.apply(op).unwrap() +} + +/// The label of each row of the tree, by path, depth first (rows without one left out). +fn tree_labels(c: &Catalog) -> Vec<(String, ColorLabel)> { + fn walk(n: &FolderNode, out: &mut Vec<(String, ColorLabel)>) { + if let Some(l) = n.label { + out.push((n.path.clone(), l)); + } + n.children.iter().for_each(|c| walk(c, out)); + } + let mut out = Vec::new(); + c.folder_tree().iter().for_each(|n| walk(n, &mut out)); + out +} + +#[test] +fn a_new_library_knows_nothing_about_its_folders() { + let c = library(); + assert_eq!(c.folder_record("/pics/trip"), None); + assert_eq!(c.folder_color_label("/pics/trip"), None); + assert!(tree_labels(&c).is_empty()); +} + +#[test] +fn a_labelled_folder_shows_its_label_in_the_tree_and_only_there() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Red)); + assert_eq!(tree_labels(&c), vec![("/pics/trip".to_string(), ColorLabel::Red)]); +} + +#[test] +fn however_a_folder_is_spelled_it_has_one_label() { + let mut c = library(); + label(&mut c, "/pics//trip/", Some(ColorLabel::Green)); + assert_eq!(c.folder_color_label("/pics/./trip"), Some(ColorLabel::Green)); + label(&mut c, "/pics/home/../trip", Some(ColorLabel::Blue)); + assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Blue)); + assert_eq!(tree_labels(&c).len(), 1, "one folder, one record"); +} + +#[test] +fn taking_the_label_off_leaves_no_record() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Purple)); + label(&mut c, "/pics/trip", None); + assert_eq!(c.folder_record("/pics/trip"), None); + assert!(!c.to_snapshot().contains("folder_records"), "nothing empty is saved"); +} + +#[test] +fn an_empty_record_is_no_record() { + let mut c = library(); + c.apply(Op::SetFolderRecord { folder: "/pics/trip".into(), record: Some(FolderRecord::default()) }).unwrap(); + assert_eq!(c.folder_record("/pics/trip"), None); +} + +#[test] +fn undo_puts_back_the_label_the_folder_had() { + let mut c = library(); + let undo_first = label(&mut c, "/pics/trip", Some(ColorLabel::Yellow)); + let undo_second = label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + c.apply(undo_second).unwrap(); + assert_eq!(c.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow)); + c.apply(undo_first).unwrap(); + assert_eq!(c.folder_record("/pics/trip"), None); +} + +#[test] +fn a_path_that_names_no_folder_is_refused() { + let mut c = library(); + let before = c.to_snapshot(); + for bad in ["", "relative/trip", ".", "a/.."] { + let op = Op::SetFolderRecord { folder: bad.into(), record: Some(FolderRecord { label: Some(ColorLabel::Red) }) }; + assert!(c.apply(op).is_err(), "{bad:?} names no folder"); + } + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn a_moved_folder_takes_its_labels_and_its_subfolders_labels_along() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + label(&mut c, "/pics/trip/day1", Some(ColorLabel::Green)); + label(&mut c, "/pics/trip2", Some(ColorLabel::Blue)); + let ops = c.folder_records_follow("/pics/trip", "/archive/2026/trip"); + c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_record("/pics/trip"), None); + assert_eq!(c.folder_color_label("/archive/2026/trip"), Some(ColorLabel::Red)); + assert_eq!(c.folder_color_label("/archive/2026/trip/day1"), Some(ColorLabel::Green)); + assert_eq!(c.folder_color_label("/pics/trip2"), Some(ColorLabel::Blue), "a neighbour that only starts the same stays"); +} + +#[test] +fn undoing_a_move_brings_back_every_label_also_one_left_at_the_destination() { + let mut c = library(); + label(&mut c, "/pics/trip", Some(ColorLabel::Red)); + // a folder that once was at the destination left its labels behind + label(&mut c, "/pics/holiday", Some(ColorLabel::Purple)); + label(&mut c, "/pics/holiday/day9", Some(ColorLabel::Yellow)); + let before = c.to_snapshot(); + let ops = c.folder_records_follow("/pics/trip", "/pics/holiday"); + let undo = c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Red), "the moved folder's label wins"); + assert_eq!(c.folder_record("/pics/holiday/day9"), None, "what was known about the old folder goes"); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); +} + +#[test] +fn a_folder_without_labels_moves_without_ops() { + let c = library(); + assert!(c.folder_records_follow("/pics/trip", "/elsewhere/trip").is_empty()); +} + +#[test] +fn an_unlabelled_folder_moved_onto_a_stale_label_does_not_inherit_it() { + let mut c = library(); + // a folder that was at the destination once (its photos are gone) left its label behind + label(&mut c, "/pics/holiday", Some(ColorLabel::Purple)); + let ops = c.folder_records_follow("/pics/trip", "/pics/holiday"); + let undo = c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.folder_record("/pics/holiday"), None, "the folder now there is another one"); + c.apply(undo).unwrap(); + assert_eq!(c.folder_color_label("/pics/holiday"), Some(ColorLabel::Purple)); +} + +#[test] +fn labels_survive_the_journal_without_a_snapshot() { + let m = MemStore::new(); + let (mut j, mut c, _) = Journal::open(Box::new(m.clone())).unwrap(); + { + let p = "/pics/trip/a.jpg"; + let id = c.alloc_photo_id(); + let op = Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: p.into() }, "a.jpg", "JPEG", 6, 4, "2026-01-01")) }; + c.apply(op.clone()).unwrap(); + j.append(std::slice::from_ref(&op)).unwrap(); + } + let op = c.folder_label_op("/pics/trip", Some(ColorLabel::Yellow)); + c.apply(op.clone()).unwrap(); + j.append(std::slice::from_ref(&op)).unwrap(); + drop(j); + let (_, reopened, r) = Journal::open(Box::new(m)).unwrap(); + assert!(r.replayed >= 2, "{r:?}"); + assert_eq!(reopened.folder_color_label("/pics/trip"), Some(ColorLabel::Yellow)); +} + +#[test] +fn labels_survive_closing_and_reopening_the_library() { + let mut c = library(); + label(&mut c, "/pics/home", Some(ColorLabel::Green)); + let reopened = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + assert_eq!(reopened.folder_color_label("/pics/home"), Some(ColorLabel::Green)); +} + +#[test] +fn a_library_saved_before_folder_records_opens_without_any() { + let c = library(); + let old = c.to_snapshot(); + assert!(!old.contains("folder_records")); + let reopened = Catalog::from_snapshot(&old).unwrap(); + assert_eq!(reopened.folder_record("/pics/trip"), None); +} diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index cb80ce67e..ed2bfe7d1 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -47,7 +47,7 @@ fn v1_library_loads_and_is_upgraded() { assert_eq!((r.replayed, r.upgraded_from), (1, None)); } -/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3. +/// A library saved before albums could be ordered by hand opens as it was and is rewritten in the current format. #[test] fn v2_library_loads_and_is_upgraded() { let (base, log, full) = legacy_parts(); @@ -57,7 +57,21 @@ fn v2_library_loads_and_is_upgraded() { let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap(); assert_eq!(c.to_snapshot(), full.to_snapshot()); assert_eq!((r.replayed, r.upgraded_from), (1, Some(2))); - assert_eq!(snapshot_version(&m), 3); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + +/// A library saved before keywords could be listed on their own opens as it was and is rewritten +/// in the current format. +#[test] +fn v3_library_loads_and_is_upgraded() { + let (base, log, full) = legacy_parts(); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":3,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes()); + m.set(LOG, log.into_bytes()); + let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap(); + assert_eq!(c.to_snapshot(), full.to_snapshot()); + assert_eq!((r.replayed, r.upgraded_from), (1, Some(3))); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); } #[test] @@ -193,8 +207,11 @@ fn op_variants_are_versioned() { | Op::Batch { .. } => 1, Op::SetBrowsed { .. } => 2, Op::SetAlbumOrder { .. } => 3, + Op::SetEmbeddedLens { .. } => 4, + Op::SetKeyword { .. } => 5, + Op::SetFolderRecord { .. } => 6, } } - let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None }); + let newest = since(&Op::SetFolderRecord { folder: "/".into(), record: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 0594b6d83..8570bd077 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -78,6 +78,15 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), 9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) }, + 9 if b % 7 == 5 => Op::SetKeyword { + path: format!("travel|place {}", b % 4), + info: (!b.is_multiple_of(3)).then(|| crate::keywords::KeywordInfo { + person: a.is_multiple_of(2), + synonyms: vec![format!("trip {a}")], + ..Default::default() + }), + }, + 9 if b % 11 == 4 => c.folder_label_op(&format!("/p/f{}", a % 4), ColorLabel::ALL.get(usize::from(b % 6)).copied()), 9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) }, 9 if b.is_multiple_of(2) => { Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } } @@ -326,3 +335,44 @@ fn failed_streamed_write_keeps_the_old_file() { assert_eq!(std::fs::read(dir.join(SNAPSHOT)).unwrap(), b"new"); let _ = std::fs::remove_dir_all(&dir); } + +/// A damaged rendering process in a saved photo (snapshot or log) reads as V1 and costs nothing +/// else: the photo and its edits load (`docs/process-versions.md`). +#[test] +fn a_damaged_process_value_loads_as_v1_with_the_edits() { + use lightcraft_develop::ProcessVersion; + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00"); + p.develop = Arc::new(DevelopSettings { + process: ProcessVersion(7), + light: lightcraft_develop::Light { exposure: 0.5, ..Default::default() }, + ..Default::default() + }); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let snapshot = c.to_snapshot(); + assert!(snapshot.contains(r#""process":7"#)); + let snapshot = format!( + "{{\"format\":\"lightcraft-catalog\",\"version\":{},\"seq\":0,\"catalog\":{}}}\n", + crate::journal::VERSION, + snapshot.replace(r#""process":7"#, r#""process":-1"#) + ); + let m = MemStore::new(); + m.set(SNAPSHOT, snapshot.into_bytes()); + let (_, loaded, _) = open(&m); + let d = &loaded.photo(id).unwrap().develop; + assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5)); + // and in a (CRC-valid) log record + let edit = DevelopSettings { + process: ProcessVersion(7), + light: lightcraft_develop::Light { exposure: 0.8, ..Default::default() }, + ..Default::default() + }; + let op = Op::SetDevelop { id, settings: Arc::new(edit), label: "Edit".into(), edited: None }; + let body = serde_json::to_string(&op).unwrap().replace(r#""process":7"#, r#""process":"seven""#); + assert!(body.contains("seven")); + m.set(LOG, format!("{{\"seq\":1,\"crc\":{},\"op\":{body}}}\n", crc32fast::hash(body.as_bytes())).into_bytes()); + let (_, loaded, _) = open(&m); + let d = &loaded.photo(id).unwrap().develop; + assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8)); +} diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 18d00ac3e..4c93a89a6 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -30,6 +30,8 @@ jxl-oxide = { workspace = true, optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ravif = { workspace = true, optional = true } +rav1e = { workspace = true, optional = true } +avif-serialize = { workspace = true, optional = true } [features] default = ["parallel", "jxl", "avif"] @@ -40,7 +42,9 @@ jxl = ["dep:jxl-oxide"] # PhotoCraft); `cargo xtask ci` clippies and tests it (`--features heif`), releases don't ship it yet. heif = ["dep:lightcraft-heif"] ## AVIF encoding via ravif/rav1e (pure Rust, BSD). Native targets only; a no-op on wasm32. -avif = ["dep:ravif"] +avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"] [dev-dependencies] proptest = { workspace = true } +# Lossless synthetic HEIC files for the `heif` feature's tests. +lightcraft-heif = { workspace = true, features = ["testdata"] } diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 18743ad03..e1998c49d 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -23,6 +23,12 @@ encode_png(&EncodeImage::new(w, h, 4, Samples::U16(&px)), &meta)?; encode_tiff(&EncodeImage::new(w, h, 3, Samples::F32(&px)), TiffCompression::Deflate, &meta)?; encode_webp_lossless(&img, &meta)?; encode_avif(&img, 70, 6, &meta)?; // native + feature `avif` + +// HDR gain map JPEG (ISO 21496-1): SDR + HDR renditions → base + gain map, and back +let (map, gm) = gainmap::compute(&sdr_linear, &hdr_linear, w, h, luma, &GainMapOptions::default())?; +gainmap::encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&base8)), &map, &gm, 90, ChromaSubsampling::S444, &meta)?; +let found = gainmap::read_jpeg(&bytes); // ours, cameras', Lightroom's +gainmap::apply(&mut base_linear, w, h, &found.gain, &found.meta, 1.0)?; ``` Thumbnail fallback decoding limits the source to 64 million pixels by default. @@ -46,8 +52,9 @@ CRW, MRW, X3F) are detected so the engine can route them to `lightcraft-raw`; `d | GIF / BMP | yes (first GIF frame) | — | via `image` | | PSD / PSB | merged composite: 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw/RLE | — | ICC (1039), EXIF (1058), XMP (1060); ZIP-compressed composite, Lab and 1-bit unsupported | | JPEG XL | yes (jxl-oxide, feature `jxl`, default on) | — | enum colour → rendered straight to linear Rec.2020; ICC → our ICC path; orientation applied by the decoder (reported as 1) | -| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8-bit sRGB, EXIF; no ICC in the muxer | -| HEIC/HEIF | **no** (sniff only) | — | see gaps | +| AVIF | **no** | yes (ravif/rav1e, native only, feature `avif`) | 8/10-bit sRGB, EXIF; no ICC in the muxer. HDR: `encode_avif_pq` writes 10-bit BT.2020 PQ (SDR white at 203 cd/m², CICP `nclx` + `clli`) | +| HEIC/HEIF | yes with feature `heif` (lightcraft-heif: heic-rs + libheif-matching colour; 4:2:0/mono, 8–10-bit, grids, irot/imir/clap, ICC/nclx/VUI, EXIF/XMP, thumbnail item) | — | 4:2:2/4:4:4 (Canon/Sony HIF) unsupported | +| Gain map JPEG (HDR) | gain map found and decoded (`gainmap::read_jpeg`): ISO 21496-1 metadata, else Adobe `hdrgm` XMP | yes (`gainmap::encode_jpeg`) | CIPA DC-007 MPF index (gain map typed `0x050000`), ISO 21496-1 APP2 on both images, `hdrgm` + Container XMP for Android/Chrome, Apple `HDRGainMap`/`HDRToneMap` XMP; gain maps are never used as thumbnails | ## Colour @@ -100,9 +107,11 @@ Run `cargo test --release -p lightcraft-codecs --test bench -- --ignored --nocap ## Gaps / limitations -- **AVIF and HEIC decode**: no permissively licensed, pure-Rust AV1/HEVC decoder exists today - (dav1d/libheif are C; rav1d-safe and imazen's heic are AGPL). Files are sniffed and rejected with - `Error::Unsupported`; a raw-style embedded-preview path could be added for HEIC thumbnails. +- **AVIF decode**: no permissively licensed, pure-Rust AV1 decoder exists today (dav1d is C; + rav1d-safe is AGPL). Files are sniffed and rejected with `Error::Unsupported`. +- **HEIC** needs the `heif` feature (off by default: HEVC patents are the distributor's call); + without it, `Error::Unsupported` says the build has no HEIC support. 4:2:2 and 4:4:4 HEIF + (Canon/Sony HIF) is refused: heic-rs 0.1.1 loses sync on those bitstreams. - JPEG: arithmetic coding unsupported (as in both decoders); extended XMP (> 64 KiB) neither read nor written; EXIF > 64 KiB not written. The encoder uses standard Huffman tables (interleaved baseline) for maximum compatibility. diff --git a/crates/codecs/src/display.rs b/crates/codecs/src/display.rs new file mode 100644 index 000000000..e26e6ab6e --- /dev/null +++ b/crates/codecs/src/display.rs @@ -0,0 +1,442 @@ +//! Monitor (display) ICC profiles: showing photos correctly on wide-gamut and calibrated displays. +//! +//! The loupe renders straight into the display's own primaries: the develop pipeline targets the +//! display's linear RGB ([`DisplayProfile::to_rec2020`]) and encodes it with the sRGB curve, as it +//! does for every 8-bit output. That "proxy" encoding then goes to the display's real device +//! values with [`DisplayProfile::correct`]: its tone curves for a matrix/TRC profile (a per-channel +//! table), or its LUTs for a LUT-based profile (a CMS transform). Images made for sRGB (thumbnails, +//! cached previews, embedded camera JPEGs) go through [`DisplayProfile::from_srgb`] instead. +//! +//! All conversions are relative colorimetric: the display's white is the image's white. + +use std::sync::Arc; + +use crate::icc; +use crate::space::Trc; +use lightcraft_color::{D50, D65, Mat3, REC2020, bradford}; +use lightcraft_raster::Rgba8; +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor, TransformOptions, Xyzd}; + +/// Largest profile file accepted (monitor profiles are a few KB; LUT profiles up to a few MB). +pub const MAX_PROFILE_BYTES: usize = 32 << 20; + +/// How a display profile describes the display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisplayKind { + /// Primaries + tone curves (exact). + MatrixTrc, + /// LUT-based (A2B/B2A tables): applied through the CMS; the primaries the pipeline targets are + /// the profile's matrix tags when it has them, else measured through its tables. + Lut, +} + +impl DisplayKind { + pub fn label(self) -> &'static str { + match self { + DisplayKind::MatrixTrc => "matrix", + DisplayKind::Lut => "lut", + } + } +} + +/// The proxy → device step of a display. +enum Correction { + /// The proxy encoding is the display's (sRGB-curve matrix profile): nothing to do. + None, + /// Per-channel tables (matrix/TRC profile: same primaries, other curves). + Curves(Box<[[u8; 256]; 3]>), + /// A full CMS transform (LUT-based profile). + Cms(Arc), +} + +/// A parsed display profile with its transforms (built once, applied to every preview). +pub struct DisplayProfile { + /// The profile's own description (or "Display profile"). + pub description: String, + /// Identifies the profile (hash of its bytes): part of render keys. + pub id: u64, + pub kind: DisplayKind, + /// Linear display RGB → linear Rec.2020 (D65; display white → (1, 1, 1)). + pub to_rec2020: Mat3, + correction: Correction, + from_srgb: Arc, + to_srgb: Arc, +} + +impl std::fmt::Debug for DisplayProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DisplayProfile") + .field("description", &self.description) + .field("id", &self.id) + .field("kind", &self.kind) + .field("to_rec2020", &self.to_rec2020) + .finish_non_exhaustive() + } +} + +fn opts() -> TransformOptions { + TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() } +} + +fn xyzd(v: [f64; 3]) -> Xyzd { + Xyzd { x: v[0], y: v[1], z: v[2] } +} + +/// Linear Rec.2020 → XYZ relative to D50 (the ICC PCS), Bradford-adapted. +fn rec2020_to_xyz_d50() -> Mat3 { + bradford(D65, D50).mul(&REC2020.to_xyz()) +} + +/// Scale `m`'s columns so that (1, 1, 1) maps to (1, 1, 1): the display's white is the image's. +fn white_balanced(m: Mat3) -> Option { + let s = m.inverse()?.apply([1.0; 3]); + s.iter().all(|v| v.is_finite() && *v > 0.0).then(|| m.mul(&Mat3::diag(s[0], s[1], s[2]))) +} + +fn hash(bytes: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + h.finish() | 1 +} + +/// Runs a moxcms call that may panic on a hostile profile. +fn guarded(f: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +impl DisplayProfile { + /// Parse a display profile (an RGB ICC profile, matrix/TRC or LUT-based) and build its + /// transforms. Errors say what is wrong with the file; nothing here panics. + pub fn from_icc(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(format!("the profile is too large ({} MB)", bytes.len() >> 20)); + } + let p = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?; + if p.color_space != DataColorSpace::Rgb { + return Err("not an RGB profile (a display profile describes an RGB device)".into()); + } + if !matches!(p.pcs, DataColorSpace::Xyz | DataColorSpace::Lab) { + return Err("unsupported profile connection space".into()); + } + let info = icc::parse(bytes).ok_or("not a valid ICC profile")?; + let description = info + .description + .as_deref() + .map(|d| d.trim_start_matches('\u{feff}').trim()) + .filter(|d| !d.is_empty()) + .unwrap_or("Display profile") + .to_string(); + let has_lut = p.lut_a_to_b_colorimetric.is_some() + || p.lut_a_to_b_perceptual.is_some() + || p.lut_b_to_a_colorimetric.is_some() + || p.lut_b_to_a_perceptual.is_some(); + let srgb = ColorProfile::new_srgb(); + + // the primaries the pipeline renders into + let (kind, to_xyz_d50, curves) = match &info.kind { + icc::IccKind::MatrixTrc { to_xyz_d50, trc } if !has_lut => (DisplayKind::MatrixTrc, *to_xyz_d50, Some(trc.clone())), + icc::IccKind::MatrixTrc { to_xyz_d50, .. } => (DisplayKind::Lut, *to_xyz_d50, None), + _ => (DisplayKind::Lut, measured_to_xyz_d50(&p)?, None), + }; + let to_rec2020 = + rec2020_to_xyz_d50().inverse().map(|m| m.mul(&to_xyz_d50)).and_then(white_balanced).ok_or("the profile's primaries are degenerate")?; + if to_rec2020.0.iter().flatten().any(|v| !v.is_finite()) { + return Err("the profile's primaries are degenerate".into()); + } + + // the proxy: the display's primaries (as the pipeline targets them), sRGB curve + let proxy_xyz = rec2020_to_xyz_d50().mul(&to_rec2020).0; + let mut proxy = ColorProfile::new_srgb(); + proxy.cicp = None; + proxy.red_colorant = xyzd([proxy_xyz[0][0], proxy_xyz[1][0], proxy_xyz[2][0]]); + proxy.green_colorant = xyzd([proxy_xyz[0][1], proxy_xyz[1][1], proxy_xyz[2][1]]); + proxy.blue_colorant = xyzd([proxy_xyz[0][2], proxy_xyz[1][2], proxy_xyz[2][2]]); + + let transform = |src: &ColorProfile, dst: &ColorProfile| guarded(|| src.create_transform_8bit(Layout::Rgba, dst, Layout::Rgba, opts()).ok()); + let correction = match curves { + Some(trc) if trc.iter().all(|t| t.approx_eq(&Trc::Srgb, 2e-4)) => Correction::None, + Some(trc) => Correction::Curves(Box::new(curve_tables(&trc))), + None => Correction::Cms(transform(&proxy, &p).ok_or("the profile's tables can't be applied")?), + }; + let from_srgb = transform(&srgb, &p).ok_or("can't convert sRGB to this profile")?; + let to_srgb = transform(&proxy, &srgb).ok_or("can't convert this profile to sRGB")?; + Ok(DisplayProfile { description, id: hash(bytes), kind, to_rec2020, correction, from_srgb, to_srgb }) + } + + /// Does [`correct`](Self::correct) change anything (the display's curves aren't sRGB's)? + pub fn needs_correction(&self) -> bool { + !matches!(self.correction, Correction::None) + } + + /// A render in the display's primaries with the sRGB curve (the pipeline's display target) → + /// the display's device values, in place. + pub fn correct(&self, img: &mut Rgba8) -> Result<(), String> { + match &self.correction { + Correction::None => Ok(()), + Correction::Curves(t) => { + let map = |p: &mut [u8; 4]| *p = [t[0][p[0] as usize], t[1][p[1] as usize], t[2][p[2] as usize], p[3]]; + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + img.data.par_chunks_mut(1 << 14).for_each(|c| c.iter_mut().for_each(map)); + } + #[cfg(not(feature = "parallel"))] + img.data.iter_mut().for_each(map); + Ok(()) + } + Correction::Cms(x) => apply(x, img), + } + } + + /// An sRGB image → the display's device values, in place. + pub fn from_srgb(&self, img: &mut Rgba8) -> Result<(), String> { + apply(&self.from_srgb, img) + } + + /// A render in the display's primaries with the sRGB curve → sRGB (colours outside sRGB are + /// clipped): for histograms and cached previews, which stay sRGB. + pub fn to_srgb(&self, img: &Rgba8) -> Result { + let mut out = img.clone(); + apply(&self.to_srgb, &mut out)?; + Ok(out) + } +} + +/// Device RGB → PCS (XYZ D50) of the pure primaries, measured through the profile's tables +/// (LUT-only profiles have no colorant tags). +fn measured_to_xyz_d50(p: &ColorProfile) -> Result { + let lin = icc::linear_rec2020_profile(); + let x = guarded(|| p.create_transform_f32(Layout::Rgb, &lin, Layout::Rgb, opts()).ok()).ok_or("the profile's tables can't be applied")?; + let src = [1.0f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + let mut dst = [0f32; 9]; + guarded(|| x.transform(&src, &mut dst).ok()).ok_or("the profile's tables can't be applied")?; + let c = |i: usize| [dst[i] as f64, dst[i + 3] as f64, dst[i + 6] as f64]; + // columns: each primary in linear Rec.2020 + let m = Mat3([c(0), c(1), c(2)]); + if m.0.iter().flatten().any(|v| !v.is_finite()) || m.determinant().abs() < 1e-6 { + return Err("the profile's primaries are degenerate".into()); + } + Ok(rec2020_to_xyz_d50().mul(&m)) +} + +/// sRGB-encoded 8-bit value → the display curve's code value, per channel. +fn curve_tables(trc: &[Trc; 3]) -> [[u8; 256]; 3] { + let mut t = [[0u8; 256]; 3]; + for (k, tab) in t.iter_mut().enumerate() { + for (i, v) in tab.iter_mut().enumerate() { + let lin = lightcraft_color::transfer::srgb_to_linear(i as f32 / 255.0); + *v = (from_linear(&trc[k], lin) * 255.0 + 0.5).clamp(0.0, 255.0) as u8; + } + } + t +} + +/// Inverse of [`Trc::to_linear`] on 0..1 (bisection: works for tables and every parametric form). +fn from_linear(t: &Trc, y: f32) -> f32 { + let y = y.clamp(0.0, 1.0); + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..24 { + let mid = 0.5 * (lo + hi); + if t.to_linear(mid) < y { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} + +fn apply(x: &Arc, img: &mut Rgba8) -> Result<(), String> { + const CHUNK: usize = 1 << 14; // pixels + let src = img.data.clone(); + let run = |s: &[[u8; 4]], d: &mut [[u8; 4]]| guarded(|| x.transform(s.as_flattened(), d.as_flattened_mut()).ok()).is_some(); + #[cfg(feature = "parallel")] + let ok = { + use rayon::prelude::*; + src.par_chunks(CHUNK).zip(img.data.par_chunks_mut(CHUNK)).all(|(s, d)| run(s, d)) + }; + #[cfg(not(feature = "parallel"))] + let ok = src.chunks(CHUNK).zip(img.data.chunks_mut(CHUNK)).all(|(s, d)| run(s, d)); + if ok { Ok(()) } else { Err("display profile transform failed".into()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::NamedSpace; + use lightcraft_color::{DISPLAY_P3, SRGB}; + + fn img(px: &[[u8; 4]]) -> Rgba8 { + Rgba8 { width: px.len(), height: 1, data: px.to_vec() } + } + + fn close(a: [u8; 4], b: [u8; 4], tol: i32) -> bool { + (0..4).all(|i| (a[i] as i32 - b[i] as i32).abs() <= tol) + } + + #[test] + fn srgb_display_changes_nothing() { + let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::Srgb)).unwrap(); + assert_eq!(d.kind, DisplayKind::MatrixTrc); + assert!(!d.needs_correction()); + for (i, row) in d.to_rec2020.0.iter().enumerate() { + let want = SRGB.to_space(&REC2020).0[i]; + for k in 0..3 { + assert!((row[k] - want[k]).abs() < 2e-3, "{:?}", d.to_rec2020); + } + } + let px = [[0, 0, 0, 255], [255, 255, 255, 255], [200, 30, 90, 128], [17, 140, 251, 7]]; + let mut a = img(&px); + d.correct(&mut a).unwrap(); + assert_eq!(a.data, px); + let mut b = img(&px); + d.from_srgb(&mut b).unwrap(); + for (x, y) in b.data.iter().zip(&px) { + assert!(close(*x, *y, 1), "{x:?} {y:?}"); + } + } + + /// On a Display P3 monitor, sRGB red is shown with less red drive than full; P3 green (out of + /// sRGB) is reachable in the display's own primaries. + #[test] + fn p3_display() { + let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap(); + assert!(!d.needs_correction(), "P3 uses the sRGB curve"); + let want = DISPLAY_P3.to_space(&REC2020).0; + for i in 0..3 { + for k in 0..3 { + assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 2e-3, "{:?}", d.to_rec2020); + } + } + let mut red = img(&[[255, 0, 0, 255], [128, 128, 128, 255]]); + d.from_srgb(&mut red).unwrap(); + assert!(red.data[0][0] < 245 && red.data[0][1] > 40, "{:?}", red.data[0]); + assert!(close(red.data[1], [128, 128, 128, 255], 1), "grey stays grey: {:?}", red.data[1]); + // the display's own pure green is outside sRGB: it clips there + let srgb = d.to_srgb(&img(&[[0, 255, 0, 255]])).unwrap(); + assert!(srgb.data[0][0] < 10 && srgb.data[0][1] == 255, "{:?}", srgb.data[0]); + } + + /// A gamma 2.2 display (Adobe RGB-like): the proxy's sRGB curve goes to the display's curve. + #[test] + fn gamma_display_uses_its_curve() { + let bytes = icc::write_matrix_trc(&lightcraft_color::ADOBE_RGB, &Trc::Gamma(2.2)); + let d = DisplayProfile::from_icc(&bytes).unwrap(); + assert!(d.needs_correction()); + let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [10, 128, 240, 9]]); + d.correct(&mut a).unwrap(); + assert_eq!(a.data[0], [0, 0, 0, 255]); + assert_eq!(a.data[1], [255, 255, 255, 255]); + for (k, v) in [10u8, 128, 240].into_iter().enumerate() { + let lin = lightcraft_color::transfer::srgb_to_linear(v as f32 / 255.0); + let want = (lin.powf(1.0 / 2.2) * 255.0).round() as i32; + assert!((a.data[2][k] as i32 - want).abs() <= 1, "{k}: {} vs {want}", a.data[2][k]); + } + assert_eq!(a.data[2][3], 9, "alpha kept"); + } + + #[test] + fn rejects_bad_profiles() { + assert!(DisplayProfile::from_icc(&[]).is_err()); + assert!(DisplayProfile::from_icc(&[0u8; 300]).is_err()); + let mut gray = ColorProfile::new_gray_with_gamma(2.2); + gray.cicp = None; + assert!(DisplayProfile::from_icc(&gray.encode().unwrap()).is_err()); + // truncated / corrupted real profiles never panic + let good = icc::write_named(NamedSpace::DisplayP3); + for n in [10, 128, 140, 200, good.len() - 1] { + let _ = DisplayProfile::from_icc(&good[..n]); + } + for i in (0..good.len()).step_by(7) { + let mut b = good.clone(); + b[i] ^= 0xa5; + let _ = DisplayProfile::from_icc(&b); + } + } + + /// A LUT-based display profile with no colorant or TRC tags (its A2B/B2A tables describe a + /// Display P3 panel with a gamma 2.2 response): the primaries are measured through the tables + /// and every preview goes through the CMS. + fn lut_only_p3_gamma22() -> Vec { + use moxcms::{LutMultidimensionalType, LutWarehouse, Matrix3d, ProfileClass, ToneReprCurve, Vector3d}; + let m = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).0; + let inv = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).inverse().unwrap().0; + let curves = |v: Vec| vec![ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v)]; + let ident = || curves(vec![1.0]); + // mAB: A → CLUT → M → matrix → B (no CLUT: M curves linearize, the matrix goes to XYZ) + let a2b = LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: 3, + num_output_channels: 3, + grid_points: [0; 16], + clut: None, + a_curves: vec![], + m_curves: curves(vec![2.2]), + matrix: Matrix3d { v: m.map(|r| r.map(|v| v * 32768.0 / 65535.0)) }, + b_curves: ident(), + bias: Vector3d::default(), + }); + let b2a = LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: 3, + num_output_channels: 3, + grid_points: [0; 16], + clut: None, + a_curves: vec![], + m_curves: curves(vec![1.0 / 2.2]), + matrix: Matrix3d { v: inv.map(|r| r.map(|v| v * 65535.0 / 32768.0)) }, + b_curves: ident(), + bias: Vector3d::default(), + }); + let mut p = ColorProfile::new_srgb(); + p.cicp = None; + p.profile_class = ProfileClass::DisplayDevice; + p.red_colorant = Xyzd::default(); + p.green_colorant = Xyzd::default(); + p.blue_colorant = Xyzd::default(); + p.red_trc = None; + p.green_trc = None; + p.blue_trc = None; + p.lut_a_to_b_colorimetric = Some(a2b.clone()); + p.lut_a_to_b_perceptual = Some(a2b); + p.lut_b_to_a_colorimetric = Some(b2a.clone()); + p.lut_b_to_a_perceptual = Some(b2a); + p.encode().unwrap() + } + + #[test] + fn lut_profile_goes_through_the_cms() { + let bytes = lut_only_p3_gamma22(); + let parsed = ColorProfile::new_from_slice(&bytes).unwrap(); + assert!(!parsed.is_matrix_shaper() && parsed.lut_b_to_a_colorimetric.is_some(), "a LUT-only profile"); + let d = DisplayProfile::from_icc(&bytes).unwrap(); + assert_eq!(d.kind, DisplayKind::Lut); + assert!(d.needs_correction()); + // the measured primaries are P3's + let want = DISPLAY_P3.to_space(&REC2020).0; + for i in 0..3 { + for k in 0..3 { + assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 0.02, "{:?} vs {want:?}", d.to_rec2020); + } + } + // the correction is the gamma 2.2 curve; grey stays grey + let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [128, 128, 128, 200]]); + d.correct(&mut a).unwrap(); + assert!(close(a.data[0], [0, 0, 0, 255], 1) && close(a.data[1], [255, 255, 255, 255], 1), "{:?}", a.data); + let lin = lightcraft_color::transfer::srgb_to_linear(128.0 / 255.0); + let g = (lin.powf(1.0 / 2.2) * 255.0).round() as u8; + assert!(close(a.data[2], [g, g, g, 200], 2), "{:?} vs {g}", a.data[2]); + // sRGB red → less than full red drive on a P3 panel + let mut red = img(&[[255, 0, 0, 255]]); + d.from_srgb(&mut red).unwrap(); + assert!(red.data[0][0] < 245 && red.data[0][1] > 30, "{:?}", red.data[0]); + } + + #[test] + fn ids_identify_profiles() { + let a = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap(); + let b = DisplayProfile::from_icc(&icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + let a2 = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap(); + assert_ne!(a.id, b.id); + assert_eq!(a.id, a2.id); + } +} diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 596220734..c002abd98 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -79,6 +79,46 @@ pub enum ChromaSubsampling { const XMP_NS: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; +/// The APP segments [`encode_jpeg`] writes for `meta` (marker byte, payload): JFIF density, EXIF, +/// XMP and the ICC profile in chunks, in that order. +pub(crate) fn jpeg_app_segments(meta: &EncodeMeta) -> Result)>> { + let mut segs: Vec<(u8, Vec)> = Vec::new(); + let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| { + let [hi, lo] = p.to_be_bytes(); + [1, hi, lo, hi, lo] + }); + segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat())); + if let Some(exif) = meta.exif { + if exif.len() > 65_527 { + return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); + } + segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat())); + } + if let Some(xmp) = meta.xmp { + segs.push((0xE1, xmp_segment(xmp)?)); + } + if let Some(icc) = meta.icc { + // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count + let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); + if chunks.len() > 255 { + return Err(Error::Encode("ICC profile too large for JPEG".into())); + } + for (i, c) in chunks.iter().enumerate() { + segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat())); + } + } + Ok(segs) +} + +/// An APP1 XMP payload (namespace + packet), refusing packets larger than one segment. +pub(crate) fn xmp_segment(xmp: &str) -> Result> { + let seg = [XMP_NS, xmp.as_bytes()].concat(); + if seg.len() > 65_533 { + return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into())); + } + Ok(seg) +} + /// Encode baseline JPEG (8-bit; alpha is dropped). `quality` 1..=100. pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsampling, meta: &EncodeMeta) -> Result> { img.validate()?; @@ -97,35 +137,7 @@ pub fn encode_jpeg(img: &EncodeImage, quality: u8, subsampling: ChromaSubsamplin }; if subsampling != ChromaSubsampling::S422 { // Parallel encoder (restart-interval bands on all cores). - let mut segs: Vec<(u8, Vec)> = Vec::new(); - let ppi = meta.ppi.filter(|p| *p > 0).map_or([0u8, 0, 1, 0, 1], |p| { - let [hi, lo] = p.to_be_bytes(); - [1, hi, lo, hi, lo] - }); - segs.push((0xE0, [&b"JFIF\0\x01\x02"[..], &ppi, &[0, 0]].concat())); - if let Some(exif) = meta.exif { - if exif.len() > 65_527 { - return Err(Error::Encode("EXIF larger than one APP1 segment (64 KiB)".into())); - } - segs.push((0xE1, [&b"Exif\0\0"[..], exif].concat())); - } - if let Some(xmp) = meta.xmp { - let seg = [XMP_NS, xmp.as_bytes()].concat(); - if seg.len() > 65_533 { - return Err(Error::Encode("XMP larger than one APP1 segment (extended XMP not supported)".into())); - } - segs.push((0xE1, seg)); - } - if let Some(icc) = meta.icc { - // ICC.1 Annex B.4: "ICC_PROFILE\0", 1-based chunk number, chunk count - let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); - if chunks.len() > 255 { - return Err(Error::Encode("ICC profile too large for JPEG".into())); - } - for (i, c) in chunks.iter().enumerate() { - segs.push((0xE2, [&b"ICC_PROFILE\0"[..], &[i as u8 + 1, chunks.len() as u8], c].concat())); - } - } + let segs = jpeg_app_segments(meta)?; let ch = if img.channels == 2 { 1 } else { img.channels as usize }; // `data` is already gray for 2-channel input (see above) return Ok(crate::jpeg_par::encode(&data, img.width as usize, img.height as usize, ch, quality, subsampling, &segs)); @@ -351,6 +363,141 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) } } +/// Absolute luminance of SDR white in an HDR file (ITU-R BT.2408 reference white), cd/m². +pub const HDR_REFERENCE_WHITE_NITS: f32 = 203.0; + +/// SMPTE ST 2084 (PQ) inverse EOTF: luminance in cd/m² → signal 0..1. +pub fn pq_encode(nits: f32) -> f32 { + const M1: f32 = 2610.0 / 16384.0; + const M2: f32 = 2523.0 / 4096.0 * 128.0; + const C1: f32 = 3424.0 / 4096.0; + const C2: f32 = 2413.0 / 4096.0 * 32.0; + const C3: f32 = 2392.0 / 4096.0 * 32.0; + let y = (nits / 10_000.0).clamp(0.0, 1.0).powf(M1); + ((C1 + C2 * y) / (1.0 + C3 * y)).powf(M2) +} + +/// SMPTE ST 2084 (PQ) EOTF: signal 0..1 → luminance in cd/m². +pub fn pq_decode(e: f32) -> f32 { + const M1: f32 = 2610.0 / 16384.0; + const M2: f32 = 2523.0 / 4096.0 * 128.0; + const C1: f32 = 3424.0 / 4096.0; + const C2: f32 = 2413.0 / 4096.0 * 32.0; + const C3: f32 = 2392.0 / 4096.0 * 32.0; + let p = e.clamp(0.0, 1.0).powf(1.0 / M2); + ((p - C1).max(0.0) / (C2 - C3 * p)).powf(1.0 / M1) * 10_000.0 +} + +/// Encode an HDR AVIF: `rgb` is linear light in **Rec. 2020** primaries with SDR white at 1.0 +/// (`width × height × 3`). Written as 10-bit 4:4:4 BT.2020 non-constant-luminance YCbCr with the +/// PQ transfer curve, SDR white at [`HDR_REFERENCE_WHITE_NITS`], and a content light level box. +/// `quality` 1..=100, `speed` 1..=10. Native targets with the `avif` feature only. +pub fn encode_avif_pq(width: u32, height: u32, rgb: &[f32], quality: u8, speed: u8, meta: &EncodeMeta) -> Result> { + let (w, h) = (width as usize, height as usize); + let n = w.checked_mul(h).ok_or_else(|| Error::Encode("image too large".into()))?; + if n == 0 || n.checked_mul(3).is_none_or(|need| rgb.len() < need) { + return Err(Error::Encode("HDR AVIF: sample buffer too short".into())); + } + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + use rav1e::prelude::*; + const K: [f32; 3] = [0.2627, 0.6780, 0.0593]; // BT.2020 NCL + let mut max_nits = 0f32; + let mut sum_nits = 0f64; + let planes: Vec<[u16; 3]> = rgb + .as_chunks::<3>() + .0 + .iter() + .take(n) + .map(|c| { + let nits = c.map(|v| (if v.is_finite() { v.max(0.0) } else { 0.0 }) * HDR_REFERENCE_WHITE_NITS); + let y_nits = K[0] * nits[0] + K[1] * nits[1] + K[2] * nits[2]; + max_nits = max_nits.max(nits[0].max(nits[1]).max(nits[2])); + sum_nits += y_nits as f64; + let [r, g, b] = nits.map(pq_encode); + let y = K[0] * r + K[1] * g + K[2] * b; + let cb = (b - y) / (2.0 * (1.0 - K[2])) + 0.5; + let cr = (r - y) / (2.0 * (1.0 - K[0])) + 0.5; + [y, cb, cr].map(|v| (v * 1023.0).round().clamp(0.0, 1023.0) as u16) + }) + .collect(); + let q = quality.clamp(1, 100) as f32 / 100.0; + // ravif's quality → quantizer curve, so qualities match the SDR AVIF path + 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 + }; + let quantizer = (x * 255.0).round() as usize; + let mut ec = EncoderConfig::with_speed_preset(speed.clamp(1, 10)); + ec.width = w; + ec.height = h; + ec.time_base = Rational::new(1, 1); + ec.bit_depth = 10; + ec.chroma_sampling = ChromaSampling::Cs444; + ec.pixel_range = PixelRange::Full; + ec.color_description = Some(ColorDescription { + color_primaries: ColorPrimaries::BT2020, + transfer_characteristics: TransferCharacteristics::SMPTE2084, + matrix_coefficients: MatrixCoefficients::BT2020NCL, + }); + let cll = (max_nits.ceil().clamp(1.0, 10_000.0) as u16, ((sum_nits / n as f64).ceil().clamp(1.0, 10_000.0)) as u16); + ec.content_light = Some(ContentLight { max_content_light_level: cll.0, max_frame_average_light_level: cll.1 }); + ec.still_picture = true; + ec.quantizer = quantizer; + ec.min_quantizer = quantizer.min(255) as u8; + ec.speed_settings.multiref = false; + ec.speed_settings.rdo_lookahead_frames = 1; + ec.speed_settings.scene_detection_mode = SceneDetectionSpeed::None; + let e = |e: &dyn std::fmt::Display| Error::Encode(format!("AV1: {e}")); + let mut ctx: Context = Config::new().with_encoder_config(ec).new_context().map_err(|x| e(&x))?; + let mut frame = ctx.new_frame(); + { + let mut it = planes.iter(); + let mut fp = frame.planes.iter_mut(); + let (Some(py), Some(pu), Some(pv)) = (fp.next(), fp.next(), fp.next()) else { + return Err(Error::Encode("AV1: frame has no planes".into())); + }; + let (mut sy, mut su, mut sv) = (py.mut_slice(Default::default()), pu.mut_slice(Default::default()), pv.mut_slice(Default::default())); + for ((ry, ru), rv) in sy.rows_iter_mut().zip(su.rows_iter_mut()).zip(sv.rows_iter_mut()).take(h) { + for ((y, u), v) in ry.iter_mut().zip(ru.iter_mut()).zip(rv.iter_mut()).take(w) { + let Some(p) = it.next() else { break }; + (*y, *u, *v) = (p[0], p[1], p[2]); + } + } + } + ctx.send_frame(frame).map_err(|x| e(&x))?; + ctx.flush(); + let mut av1 = Vec::new(); + loop { + match ctx.receive_packet() { + Ok(mut p) if p.frame_type == FrameType::KEY => av1.append(&mut p.data), + Ok(_) => {} + Err(EncoderStatus::Encoded | EncoderStatus::LimitReached) => break, + Err(x) => return Err(e(&x)), + } + } + use avif_serialize::constants as c; + let mut a = avif_serialize::Aviffy::new(); + a.set_matrix_coefficients(c::MatrixCoefficients::Bt2020Ncl) + .set_transfer_characteristics(c::TransferCharacteristics::Smpte2084) + .set_color_primaries(c::ColorPrimaries::Bt2020) + .set_full_color_range(true) + .set_content_light_level(cll.0, cll.1); + if let Some(exif) = meta.exif { + a.set_exif(exif.to_vec()); + } + Ok(a.to_vec(&av1, None, width, height, 10)) + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (quality, speed, meta); + Err(Error::Encode("AVIF encoding is not available in this build".into())) + } +} + /// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input). #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result> { diff --git a/crates/codecs/src/gainmap.rs b/crates/codecs/src/gainmap.rs new file mode 100644 index 000000000..13c3c0e55 --- /dev/null +++ b/crates/codecs/src/gainmap.rs @@ -0,0 +1,834 @@ +//! Gain map HDR images (ISO 21496-1): an SDR base image plus a gain map that a decoder uses to +//! rebuild the HDR rendition, so the file looks right everywhere and lights up on HDR displays. +//! +//! - [`compute`] measures a gain map from an SDR and an HDR rendition of the same picture (linear +//! light, same primaries). Measure it against the base *as written* (decoded from its 8-bit +//! codes), so quantisation in the base is handed back by the gain map. +//! - [`apply`] rebuilds the HDR rendition from a base and its gain map. +//! - [`encode_jpeg`] writes a gain map JPEG: the base with an MPF index (CIPA DC-007), the ISO +//! 21496-1 marker and Adobe/Google container XMP, followed by the gain map JPEG carrying the ISO +//! 21496-1 metadata, the Adobe `hdrgm` XMP (Android, Chrome) and Apple's description of the same +//! map (what survives an iMessage send). +//! - [`read_jpeg`] finds and decodes the gain map of a gain map JPEG (ours, cameras, Lightroom), +//! from its ISO 21496-1 metadata or, failing that, its `hdrgm` XMP. +//! +//! All gains are log2. A decoder recovers `log2(hdr + offset_hdr) − log2(sdr + offset_sdr)` as +//! `min + (max − min) · (code / 255)^(1 / gamma)`, scaled by a weight in 0..1 that depends on how +//! much headroom the display has between `base_headroom` and `alternate_headroom`. + +use crate::encode::{EncodeImage, EncodeMeta, Samples, jpeg_app_segments, xmp_segment}; +use crate::{ChromaSubsampling, Error, Format, Result, jpeg, jpeg_par}; + +/// The ISO 21496-1 namespace that opens its APP2 payloads (NUL terminated). +pub const ISO_URN: &[u8] = b"urn:iso:std:iso:ts:21496:-1\0"; + +/// Fixed denominator for the metadata's rationals: one value per setting, byte-identical output. +const DENOMINATOR: u32 = 1_000_000; + +/// Gain map parameters (ISO 21496-1 / Adobe `hdrgm`). Per-channel arrays repeat the first value +/// when the map is single-channel. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GainMapMeta { + /// Three gain channels (R, G, B) instead of one luminance gain. + pub multichannel: bool, + /// The gain applies in the base image's colour space. + pub use_base_colour_space: bool, + /// log2 headroom at which the base is shown as is (0: an SDR base). + pub base_headroom: f32, + /// log2 headroom at which the gain map applies in full. + pub alternate_headroom: f32, + pub min: [f32; 3], + pub max: [f32; 3], + pub gamma: [f32; 3], + pub base_offset: [f32; 3], + pub alternate_offset: [f32; 3], +} + +impl Default for GainMapMeta { + fn default() -> Self { + GainMapMeta { + multichannel: false, + use_base_colour_space: true, + base_headroom: 0.0, + alternate_headroom: 1.0, + min: [0.0; 3], + max: [1.0; 3], + gamma: [1.0; 3], + base_offset: [1.0 / 64.0; 3], + alternate_offset: [1.0 / 64.0; 3], + } + } +} + +impl GainMapMeta { + fn channels(&self) -> usize { + if self.multichannel { 3 } else { 1 } + } + + /// Usable by [`apply`]: finite, positive gammas, max ≥ min, headrooms in order. + pub fn is_valid(&self) -> bool { + let fin = |a: &[f32; 3]| a.iter().all(|v| v.is_finite()); + fin(&self.min) + && fin(&self.max) + && fin(&self.gamma) + && fin(&self.base_offset) + && fin(&self.alternate_offset) + && self.gamma.iter().all(|g| *g > 0.0) + && self.min.iter().zip(&self.max).all(|(a, b)| b >= a) + && self.base_headroom.is_finite() + && self.alternate_headroom.is_finite() + && self.alternate_headroom > self.base_headroom + } + + /// The ISO 21496-1 APP2 payload of the gain map image, URN included. + pub fn to_iso(&self) -> Vec { + let mut p = ISO_URN.to_vec(); + p.extend_from_slice(&0u16.to_be_bytes()); // minimum_version: readers gate on this + p.extend_from_slice(&0u16.to_be_bytes()); // writer_version + let mut flags = 0u8; + if self.multichannel { + flags |= FLAG_MULTICHANNEL; + } + if self.use_base_colour_space { + flags |= FLAG_BASE_COLOUR_SPACE; + } + p.push(flags); + let unsigned = |p: &mut Vec, v: f32| { + let n = (v as f64 * DENOMINATOR as f64).round().clamp(0.0, u32::MAX as f64) as u32; + p.extend_from_slice(&n.to_be_bytes()); + p.extend_from_slice(&DENOMINATOR.to_be_bytes()); + }; + let signed = |p: &mut Vec, v: f32| { + let n = (v as f64 * DENOMINATOR as f64).round().clamp(i32::MIN as f64, i32::MAX as f64) as i32; + p.extend_from_slice(&n.to_be_bytes()); + p.extend_from_slice(&DENOMINATOR.to_be_bytes()); + }; + unsigned(&mut p, self.base_headroom.max(0.0)); + unsigned(&mut p, self.alternate_headroom.max(0.0)); + for c in 0..self.channels() { + signed(&mut p, self.min[c]); + signed(&mut p, self.max[c]); + unsigned(&mut p, self.gamma[c]); + signed(&mut p, self.base_offset[c]); + signed(&mut p, self.alternate_offset[c]); + } + p + } + + /// Parse an ISO 21496-1 payload (the bytes after [`ISO_URN`]). `None` for the versions-only + /// marker a primary image carries, unknown versions, a base that is the HDR rendition, or + /// values no decoder could use. + pub fn from_iso(p: &[u8]) -> Option { + let mut r = Reader { b: p, at: 0 }; + let min_version = r.u16()?; + let _writer_version = r.u16()?; + if min_version != 0 { + return None; + } + let flags = r.u8()?; + if flags & FLAG_BACKWARD != 0 { + return None; + } + let multichannel = flags & FLAG_MULTICHANNEL != 0; + let common = flags & FLAG_COMMON_DENOMINATOR != 0; + let common_d = if common { r.u32()? } else { 0 }; + let unsigned = |r: &mut Reader| -> Option { + let n = r.u32()?; + let d = if common { common_d } else { r.u32()? }; + (d != 0).then(|| (n as f64 / d as f64) as f32) + }; + let signed = |r: &mut Reader| -> Option { + let n = r.u32()? as i32; + let d = if common { common_d } else { r.u32()? }; + (d != 0).then(|| (n as f64 / d as f64) as f32) + }; + let mut m = GainMapMeta { + multichannel, + use_base_colour_space: flags & FLAG_BASE_COLOUR_SPACE != 0, + base_headroom: unsigned(&mut r)?, + alternate_headroom: unsigned(&mut r)?, + ..GainMapMeta::default() + }; + for c in 0..m.channels() { + m.min[c] = signed(&mut r)?; + m.max[c] = signed(&mut r)?; + m.gamma[c] = unsigned(&mut r)?; + m.base_offset[c] = signed(&mut r)?; + m.alternate_offset[c] = signed(&mut r)?; + } + if !multichannel { + m.fill_from_first(); + } + m.is_valid().then_some(m) + } + + fn fill_from_first(&mut self) { + for a in [&mut self.min, &mut self.max, &mut self.gamma, &mut self.base_offset, &mut self.alternate_offset] { + a[1] = a[0]; + a[2] = a[0]; + } + } + + /// The gain parameters from Adobe `hdrgm` XMP (the gain map image's packet), for files + /// without ISO 21496-1 metadata (the first Ultra HDR files). Attributes or `rdf:Seq` lists. + pub fn from_hdrgm_xmp(xmp: &str) -> Option { + if !xmp.contains("hdrgm:") { + return None; + } + let base_rendition_hdr = xmp_values(xmp, "BaseRenditionIsHDR").is_some_and(|v| v.first().is_some_and(|s| s.eq_ignore_ascii_case("true"))); + if base_rendition_hdr { + return None; + } + let get = |name: &str, default: f32| -> Option<([f32; 3], bool)> { + let Some(v) = xmp_values(xmp, name) else { return Some(([default; 3], false)) }; + let f: Vec = v.iter().map(|s| s.trim().parse::().ok()).collect::>()?; + match f.as_slice() { + [a] => Some(([*a; 3], false)), + [a, b, c] => Some(([*a, *b, *c], true)), + _ => None, + } + }; + let (max, m1) = get("GainMapMax", f32::NAN)?; + if max.iter().any(|v| v.is_nan()) { + return None; // the one required parameter + } + let (min, m2) = get("GainMapMin", 0.0)?; + let (gamma, m3) = get("Gamma", 1.0)?; + let (base_offset, m4) = get("OffsetSDR", 1.0 / 64.0)?; + let (alternate_offset, m5) = get("OffsetHDR", 1.0 / 64.0)?; + let (cap_min, _) = get("HDRCapacityMin", 0.0)?; + let max_gain = max.iter().copied().fold(f32::MIN, f32::max); + let (cap_max, _) = get("HDRCapacityMax", max_gain)?; + let m = GainMapMeta { + multichannel: m1 || m2 || m3 || m4 || m5, + use_base_colour_space: true, + base_headroom: cap_min[0].max(0.0), + alternate_headroom: cap_max[0], + min, + max, + gamma, + base_offset, + alternate_offset, + }; + m.is_valid().then_some(m) + } + + /// The gain map image's XMP: the Adobe `hdrgm` parameters (read by Android and Chrome, which + /// ignore the ISO payload) and Apple's description of the same map (what iMessage keeps). + pub fn to_gain_map_xmp(&self) -> String { + let names = ["GainMapMin", "GainMapMax", "Gamma", "OffsetSDR", "OffsetHDR"]; + let values = [&self.min, &self.max, &self.gamma, &self.base_offset, &self.alternate_offset]; + let uniform = |v: &[f32; 3]| !self.multichannel || (v[0] == v[1] && v[1] == v[2]); + let mut s = String::from(XMP_OPEN); + s.push_str(r#"'); + // per-channel values are an rdf:Seq of three, not a comma list: readers parse each item + for (n, v) in names.iter().zip(values) { + if !uniform(v) { + s.push_str(&format!("")); + for x in v { + s.push_str(&format!("{}", num(*x))); + } + s.push_str(&format!("")); + } + } + s.push_str(""); + s.push_str(&self.apple_description()); + s.push_str(XMP_CLOSE); + s + } + + /// Apple's own description of the map (`apdi`, `HDRGainMap`, `HDRToneMap` namespaces). + fn apple_description(&self) -> String { + // 'L008' (8-bit luminance) or '444f'; the stored map must really have that many channels + let format = if self.multichannel { 0x3434_3466u32 } else { 0x4C30_3038 }; + let mut s = String::from( + r#""#, + ); + s.push_str("urn:com:apple:photo:2020:aux:hdrgainmap"); + s.push_str(&format!("{format}{format}")); + s.push_str("131072"); + // linear, not log2: the headroom multiplier itself + s.push_str(&format!("{}", num(self.alternate_headroom.exp2()))); + s.push_str(&format!("{}", num(self.alternate_headroom))); + s.push_str(""); + for c in 0..self.channels() { + s.push_str(&format!( + r#"{}{}{}{}{}"#, + num(self.min[c]), + num(self.max[c]), + num(self.gamma[c]), + num(self.base_offset[c]), + num(self.alternate_offset[c]) + )); + } + s.push_str(""); + s.push_str(&format!("{}", num(self.base_headroom))); + s.push_str("True1"); + s.push_str(""); + s + } +} + +const FLAG_MULTICHANNEL: u8 = 0x80; +const FLAG_BASE_COLOUR_SPACE: u8 = 0x40; +const FLAG_COMMON_DENOMINATOR: u8 = 0x08; +const FLAG_BACKWARD: u8 = 0x04; + +const XMP_OPEN: &str = ""; +const XMP_CLOSE: &str = ""; + +/// A short decimal (up to 6 significant digits, no exponent for ordinary values). +fn num(v: f32) -> String { + if !v.is_finite() { + return "0".into(); + } + let s = format!("{:.6}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.to_string() } +} + +/// The values of `hdrgm:`: an attribute (`name="v"`) or an element holding an `rdf:Seq`. +fn xmp_values(xmp: &str, name: &str) -> Option> { + let attr = format!("hdrgm:{name}="); + if let Some(i) = xmp.find(&attr) { + let rest = xmp.get(i + attr.len()..)?; + let q = rest.chars().next()?; + if q == '"' || q == '\'' { + let body = rest.get(1..)?; + let end = body.find(q)?; + return Some(vec![body.get(..end)?.to_string()]); + } + } + let open = format!(""); + let close = format!(""); + let i = xmp.find(&open)?; + let body = xmp.get(i + open.len()..)?; + let body = body.get(..body.find(&close)?)?; + let mut out = Vec::new(); + let mut rest = body; + while let Some(a) = rest.find("") { + let after = rest.get(a + 8..)?; + let b = after.find("")?; + out.push(after.get(..b)?.to_string()); + rest = after.get(b..)?; + if out.len() > 3 { + return None; + } + } + if out.is_empty() { + // a plain element value + out.push(body.trim().to_string()); + } + Some(out) +} + +struct Reader<'a> { + b: &'a [u8], + at: usize, +} + +impl Reader<'_> { + fn take(&mut self) -> Option<[u8; N]> { + let s = self.b.get(self.at..self.at.checked_add(N)?)?; + self.at += N; + s.try_into().ok() + } + fn u8(&mut self) -> Option { + self.take::<1>().map(|a| a[0]) + } + fn u16(&mut self) -> Option { + self.take().map(u16::from_be_bytes) + } + fn u32(&mut self) -> Option { + self.take().map(u32::from_be_bytes) + } +} + +/// A gain map image: `channels` (1 or 3) interleaved 8-bit codes. +#[derive(Clone, Debug, PartialEq)] +pub struct GainImage { + pub width: usize, + pub height: usize, + pub channels: usize, + pub data: Vec, +} + +impl GainImage { + /// Bilinear code of channel `c` at gain map coordinates (`x`, `y`) (pixel centres at +0.5). + fn sample(&self, x: f32, y: f32, c: usize) -> f32 { + let (w, h) = (self.width, self.height); + let fx = (x - 0.5).clamp(0.0, (w - 1) as f32); + let fy = (y - 0.5).clamp(0.0, (h - 1) as f32); + let (x0, y0) = (fx as usize, fy as usize); + let (x1, y1) = ((x0 + 1).min(w - 1), (y0 + 1).min(h - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let at = |x: usize, y: usize| self.data.get((y * w + x) * self.channels + c).copied().unwrap_or(0) as f32; + let a = at(x0, y0) + (at(x1, y0) - at(x0, y0)) * tx; + let b = at(x0, y1) + (at(x1, y1) - at(x0, y1)) * tx; + a + (b - a) * ty + } +} + +/// How [`compute`] builds the map. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GainMapOptions { + /// Downscale factor of the map (1, 2 or 4). + pub scale: usize, + /// Three gains (R, G, B) instead of one luminance gain. Apple's readers want a single + /// channel, so this is off by default. + pub multichannel: bool, + pub gamma: f32, + /// Offsets added to SDR and HDR before the ratio (keep blacks finite and noise quiet). + pub offset: f32, +} + +impl Default for GainMapOptions { + fn default() -> Self { + GainMapOptions { scale: 2, multichannel: false, gamma: 1.0, offset: 1.0 / 64.0 } + } +} + +/// Measure the gain map that turns `sdr` into `hdr` (both `width × height` linear RGB in the same +/// primaries; `luma` = that space's luminance weights). +pub fn compute( + sdr: &[[f32; 3]], + hdr: &[[f32; 3]], + width: usize, + height: usize, + luma: [f32; 3], + o: &GainMapOptions, +) -> Result<(GainImage, GainMapMeta)> { + let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?; + if n == 0 || sdr.len() < n || hdr.len() < n { + return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into())); + } + if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) { + return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into())); + } + let s = o.scale; + let (gw, gh) = (width.div_ceil(s), height.div_ceil(s)); + let ch = if o.multichannel { 3 } else { 1 }; + let y = |p: &[f32; 3]| luma[0] * p[0] + luma[1] * p[1] + luma[2] * p[2]; + let ratio = |a: f32, b: f32| ((b.max(0.0) + o.offset) / (a.max(0.0) + o.offset)).log2(); + // block means of the log gains + let mut gains = vec![0f32; gw * gh * ch]; + let mut lo = [f32::MAX; 3]; + let mut hi = [f32::MIN; 3]; + for gy in 0..gh { + for gx in 0..gw { + let mut acc = [0f32; 3]; + let mut count = 0f32; + for yy in gy * s..((gy + 1) * s).min(height) { + for xx in gx * s..((gx + 1) * s).min(width) { + let i = yy * width + xx; + let (Some(a), Some(b)) = (sdr.get(i), hdr.get(i)) else { continue }; + if o.multichannel { + for c in 0..3 { + acc[c] += ratio(a[c], b[c]); + } + } else { + acc[0] += ratio(y(a), y(b)); + } + count += 1.0; + } + } + for c in 0..ch { + let g = if count > 0.0 { acc[c] / count } else { 0.0 }; + let g = if g.is_finite() { g } else { 0.0 }; + gains[(gy * gw + gx) * ch + c] = g; + lo[c] = lo[c].min(g); + hi[c] = hi[c].max(g); + } + } + } + let mut m = GainMapMeta { + multichannel: o.multichannel, + gamma: [o.gamma; 3], + base_offset: [o.offset; 3], + alternate_offset: [o.offset; 3], + ..GainMapMeta::default() + }; + for c in 0..ch { + // the range always includes 0 (no change) so an SDR-only area encodes exactly + m.min[c] = lo[c].min(0.0); + m.max[c] = hi[c].max(m.min[c] + 1e-4); + } + if !o.multichannel { + m.fill_from_first(); + } + m.base_headroom = 0.0; + m.alternate_headroom = m.max.iter().copied().fold(0.0f32, f32::max).max(1e-3); + let data = gains + .iter() + .enumerate() + .map(|(i, g)| { + let c = i % ch; + let norm = ((g - m.min[c]) / (m.max[c] - m.min[c])).clamp(0.0, 1.0); + (norm.powf(o.gamma) * 255.0).round() as u8 + }) + .collect(); + Ok((GainImage { width: gw, height: gh, channels: ch, data }, m)) +} + +/// Rebuild the HDR rendition in place: `base` (`width × height` linear RGB) is replaced by the +/// rendition at `weight` (0 = the base, 1 = the full alternate rendition; a display with headroom +/// `H` uses `(H − base_headroom) / (alternate_headroom − base_headroom)`, clamped). +pub fn apply(base: &mut [[f32; 3]], width: usize, height: usize, gain: &GainImage, m: &GainMapMeta, weight: f32) -> Result<()> { + let n = width.checked_mul(height).ok_or_else(|| Error::Encode("gain map: image too large".into()))?; + if base.len() < n + || gain.width == 0 + || gain.height == 0 + || !matches!(gain.channels, 1 | 3) + || gain.data.len() < gain.width * gain.height * gain.channels + { + return Err(Error::Encode("gain map: base and gain map sizes don't match".into())); + } + if !m.is_valid() { + return Err(Error::Encode("gain map: unusable metadata".into())); + } + let weight = if weight.is_finite() { weight.clamp(0.0, 1.0) } else { 1.0 }; + let (sx, sy) = (gain.width as f32 / width as f32, gain.height as f32 / height as f32); + let inv_gamma = m.gamma.map(|g| 1.0 / g); + for (i, p) in base.iter_mut().enumerate().take(n) { + let (x, y) = ((i % width) as f32 + 0.5, (i / width) as f32 + 0.5); + let (gx, gy) = (x * sx, y * sy); + let mut g = [0f32; 3]; + for (c, gc) in g.iter_mut().enumerate() { + let src = if gain.channels == 3 { c } else { 0 }; + let norm = (gain.sample(gx, gy, src) / 255.0).clamp(0.0, 1.0).powf(inv_gamma[c]); + *gc = m.min[c] + (m.max[c] - m.min[c]) * norm; + } + for c in 0..3 { + let v = (p[c] + m.base_offset[c]) * (g[c] * weight).exp2() - m.alternate_offset[c]; + p[c] = v.max(0.0); + } + } + Ok(()) +} + +/// The primary image's container XMP: `hdrgm:Version` (marks the file as a gain map image for +/// Adobe/Google readers) and the Container directory naming the gain map and its length. +fn primary_description(gain_len: usize) -> String { + format!( + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ), + gain_len + ) +} + +/// `xmp` with `description` added as another `rdf:Description` (a fresh packet when there is +/// none, or when it has no `rdf:RDF` to add to). +fn merge_xmp(xmp: Option<&str>, description: &str) -> String { + if let Some(x) = xmp + && let Some(i) = x.rfind("") + && let (Some(a), Some(b)) = (x.get(..i), x.get(i..)) + { + return format!("{a}{description}{b}"); + } + format!("{XMP_OPEN}{description}{XMP_CLOSE}") +} + +/// Offsets within the MPF payload (from "MPF\0"): the endian field, then the index IFD (3 tags). +const MPF_ENTRIES_FROM_ENDIAN: usize = 8 + 2 + 3 * 12 + 4; + +/// The primary image's MPF payload, entry sizes and offset to be patched once the file exists. +fn mpf_placeholder() -> Vec { + let mut p = b"MPF\0MM".to_vec(); + p.extend_from_slice(&42u16.to_be_bytes()); + p.extend_from_slice(&8u32.to_be_bytes()); // first IFD + p.extend_from_slice(&3u16.to_be_bytes()); + let tag = |p: &mut Vec, tag: u16, ty: u16, count: u32, value: [u8; 4]| { + p.extend_from_slice(&tag.to_be_bytes()); + p.extend_from_slice(&ty.to_be_bytes()); + p.extend_from_slice(&count.to_be_bytes()); + p.extend_from_slice(&value); + }; + tag(&mut p, 0xB000, 7, 4, *b"0100"); // MPFVersion + tag(&mut p, 0xB001, 4, 1, 2u32.to_be_bytes()); // NumberOfImages + tag(&mut p, 0xB002, 7, 32, (MPF_ENTRIES_FROM_ENDIAN as u32).to_be_bytes()); // MPEntry + p.extend_from_slice(&0u32.to_be_bytes()); // no next IFD + // entry 0: baseline primary (0x030000) flagged as the representative image (bit 29) + for attr in [0x2003_0000u32, jpeg::MPF_GAIN_MAP] { + p.extend_from_slice(&attr.to_be_bytes()); + p.extend_from_slice(&[0; 12]); // size, offset, two dependent-image entries + } + p +} + +/// Encode a gain map JPEG: `base` (8-bit SDR, 3 or 4 channels, colour-managed by `meta.icc`) and +/// its gain map, both at `quality`. `meta.xmp` is kept, with the gain map container added. +pub fn encode_jpeg(base: &EncodeImage, gain: &GainImage, m: &GainMapMeta, quality: u8, sub: ChromaSubsampling, meta: &EncodeMeta) -> Result> { + let Samples::U8(data) = base.samples else { + return Err(Error::Encode("gain map JPEG needs an 8-bit base image".into())); + }; + let (w, h) = (base.width as usize, base.height as usize); + if !matches!(base.channels, 3 | 4) || data.len() < w * h * base.channels as usize { + return Err(Error::Encode("gain map JPEG needs an RGB base image".into())); + } + if w == 0 || h == 0 || w > u16::MAX as usize || h > u16::MAX as usize { + return Err(Error::Encode("JPEG dimensions are limited to 65535".into())); + } + if !matches!(gain.channels, 1 | 3) || gain.width == 0 || gain.height == 0 || gain.data.len() < gain.width * gain.height * gain.channels { + return Err(Error::Encode("gain map image is malformed".into())); + } + if !m.is_valid() || m.multichannel != (gain.channels == 3) { + return Err(Error::Encode("gain map metadata doesn't match the map".into())); + } + // jpeg_par writes 4:4:4 or 4:2:0 + let sub = if sub == ChromaSubsampling::S422 { ChromaSubsampling::S420 } else { sub }; + + let gain_segs = vec![(0xE1, xmp_segment(&m.to_gain_map_xmp())?), (0xE2, m.to_iso())]; + let gain_jpeg = jpeg_par::encode(&gain.data, gain.width, gain.height, gain.channels, quality, ChromaSubsampling::S444, &gain_segs); + + let xmp = merge_xmp(meta.xmp, &primary_description(gain_jpeg.len())); + let mut segs = jpeg_app_segments(&EncodeMeta { xmp: Some(&xmp), ..*meta })?; + let mut iso_marker = ISO_URN.to_vec(); + iso_marker.extend_from_slice(&[0, 0, 0, 0]); // versions only: "this image has a gain map" + segs.push((0xE2, iso_marker)); + segs.push((0xE2, mpf_placeholder())); + let mut primary = jpeg_par::encode(data, w, h, base.channels as usize, quality, sub, &segs); + + // patch the MPF index: sizes, and the gain map's offset from the MPF endian field + let markers = jpeg::parse_markers(&primary).ok_or_else(|| Error::Encode("gain map JPEG: primary image unreadable".into()))?; + let endian = markers.mpf.as_ref().map(|m| m.0).ok_or_else(|| Error::Encode("gain map JPEG: MPF segment missing".into()))?; + let entries = endian + MPF_ENTRIES_FROM_ENDIAN; + let (plen, glen) = (primary.len(), gain_jpeg.len()); + let fields = [(entries + 4, plen), (entries + 8, 0), (entries + 20, glen), (entries + 24, plen - endian)]; + for (at, v) in fields { + let v = u32::try_from(v).map_err(|_| Error::Encode("gain map JPEG larger than 4 GiB".into()))?; + let slot = primary.get_mut(at..at + 4).ok_or_else(|| Error::Encode("gain map JPEG: truncated MPF segment".into()))?; + slot.copy_from_slice(&v.to_be_bytes()); + } + primary.extend_from_slice(&gain_jpeg); + Ok(primary) +} + +/// The gain map of a gain map JPEG. +#[derive(Clone, Debug, PartialEq)] +pub struct JpegGainMap { + pub gain: GainImage, + pub meta: GainMapMeta, +} + +/// Find and decode the gain map of a JPEG (`None` when it has none or it's unusable). The +/// primary image is decoded as usual ([`crate::decode`]); combine the two with [`apply`]. +pub fn read_jpeg(bytes: &[u8]) -> Option { + let m = jpeg::parse_markers(bytes)?; + let marked = m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version")); + if !marked { + return None; + } + for e in jpeg::mpf_images(&m) { + let Some(data) = bytes.get(e.offset..e.offset.saturating_add(e.len)) else { continue }; + let Some(gm) = jpeg::parse_markers(data) else { continue }; + let meta = gm.iso_gainmap.as_deref().and_then(GainMapMeta::from_iso).or_else(|| gm.xmp.as_deref().and_then(GainMapMeta::from_hdrgm_xmp)); + let Some(meta) = meta else { continue }; + let Ok((w, h, ch, codes)) = jpeg::decode_codes8(data) else { continue }; + if ch == 3 && !meta.multichannel { + // a single-channel map stored as RGB: use its first channel + let g: Vec = codes.as_chunks::<3>().0.iter().map(|c| c[0]).collect(); + return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: 1, data: g }, meta }); + } + let mut meta = meta; + if ch == 1 && meta.multichannel { + meta.multichannel = false; + meta.fill_from_first(); + } + return Some(JpegGainMap { gain: GainImage { width: w, height: h, channels: ch, data: codes }, meta }); + } + None +} + +/// Whether `bytes` is a gain map JPEG with a usable gain map (cheap: no pixel decode). +pub fn is_gain_map_jpeg(bytes: &[u8]) -> bool { + crate::sniff(bytes) == Some(Format::Jpeg) + && jpeg::parse_markers(bytes).is_some_and(|m| { + (m.iso_gainmap.is_some() || m.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:Version"))) + && jpeg::mpf_images(&m) + .iter() + .any(|e| bytes.get(e.offset..e.offset.saturating_add(e.len)).and_then(jpeg::parse_markers).is_some_and(|g| g.is_gain_map())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn meta3() -> GainMapMeta { + GainMapMeta { + multichannel: true, + use_base_colour_space: true, + base_headroom: 0.0, + alternate_headroom: 2.5, + min: [-0.25, -0.5, 0.0], + max: [2.5, 2.25, 2.0], + gamma: [1.0, 1.25, 0.75], + base_offset: [0.015625; 3], + alternate_offset: [0.015625, 0.03125, 0.015625], + } + } + + #[test] + fn iso_metadata_round_trips() { + for m in [GainMapMeta { alternate_headroom: 3.0, max: [3.0; 3], ..GainMapMeta::default() }, meta3()] { + let p = m.to_iso(); + assert!(p.starts_with(ISO_URN)); + let back = GainMapMeta::from_iso(&p[ISO_URN.len()..]).unwrap(); + for (a, b) in [(back.min, m.min), (back.max, m.max), (back.gamma, m.gamma), (back.alternate_offset, m.alternate_offset)] { + for c in 0..3 { + assert!((a[c] - b[c]).abs() < 1e-6, "{a:?} {b:?}"); + } + } + assert_eq!(back.multichannel, m.multichannel); + assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-6); + } + } + + #[test] + fn iso_common_denominator_and_rejections() { + // common-denominator form, single channel: d = 4, headrooms 0 and 2, min 0, max 2, gamma 1, offsets 0 + let mut p = vec![0, 0, 0, 0, FLAG_COMMON_DENOMINATOR]; + for v in [4u32, 0, 8, 0, 8, 4, 0, 0] { + p.extend_from_slice(&v.to_be_bytes()); + } + let m = GainMapMeta::from_iso(&p).unwrap(); + assert_eq!((m.alternate_headroom, m.max[2], m.gamma[1]), (2.0, 2.0, 1.0)); + // versions only (a primary image's marker), unknown version, truncated, backward + assert!(GainMapMeta::from_iso(&[0, 0, 0, 0]).is_none()); + assert!(GainMapMeta::from_iso(&[0, 1, 0, 0, 0]).is_none()); + assert!(GainMapMeta::from_iso(&p[..p.len() - 1]).is_none()); + let mut back = p.clone(); + back[4] |= FLAG_BACKWARD; + assert!(GainMapMeta::from_iso(&back).is_none()); + // a zero denominator is rejected, not divided by + let mut z = p.clone(); + z[5..9].copy_from_slice(&0u32.to_be_bytes()); + assert!(GainMapMeta::from_iso(&z).is_none()); + } + + #[test] + fn hdrgm_xmp_round_trips_including_per_channel_sequences() { + for m in [GainMapMeta { alternate_headroom: 2.0, max: [2.0; 3], ..GainMapMeta::default() }, meta3()] { + let x = m.to_gain_map_xmp(); + assert!(x.contains("apdi:AuxiliaryImageType")); + if m.multichannel { + assert!(x.contains("2.5"), "{x}"); + } + let back = GainMapMeta::from_hdrgm_xmp(&x).unwrap(); + assert_eq!(back.multichannel, m.multichannel); + for c in 0..3 { + assert!((back.max[c] - m.max[c]).abs() < 1e-5 && (back.gamma[c] - m.gamma[c]).abs() < 1e-5); + } + assert!((back.alternate_headroom - m.alternate_headroom).abs() < 1e-5); + } + assert!(GainMapMeta::from_hdrgm_xmp("").is_none(), "no GainMapMax"); + assert!(GainMapMeta::from_hdrgm_xmp("hdrgm:GainMapMax=\"nope\"").is_none()); + } + + fn renditions(w: usize, h: usize) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { + // a ramp: SDR rolls off at 1, HDR keeps going to 6 + let hdr: Vec<[f32; 3]> = (0..w * h) + .map(|i| { + let t = (i % w) as f32 / (w - 1) as f32; + let v = 0.01 + 6.0 * t * t; + [v, v * 0.8, v * 0.6] + }) + .collect(); + // a luminance tone map (one scale per pixel, as LightCraft's renders do), so a single + // luminance gain can rebuild it exactly + let sdr = hdr + .iter() + .map(|p| { + let y = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + let k = 1.0 / (1.0 + y * 0.5); + p.map(|v| v * k) + }) + .collect(); + (sdr, hdr) + } + + #[test] + fn compute_then_apply_recovers_the_hdr_rendition() { + let (w, h) = (64, 8); + let (sdr, hdr) = renditions(w, h); + for multichannel in [false, true] { + let o = GainMapOptions { scale: 1, multichannel, ..GainMapOptions::default() }; + let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &o).unwrap(); + assert!(m.is_valid() && m.alternate_headroom > 1.0); + let mut out = sdr.clone(); + apply(&mut out, w, h, &g, &m, 1.0).unwrap(); + for (a, b) in out.iter().zip(&hdr) { + for c in 0..3 { + // 8-bit codes over the gain range: within ~2 % in linear light + assert!((a[c] - b[c]).abs() <= 0.03 * b[c] + 0.01, "{multichannel}: {a:?} vs {b:?}"); + } + } + // weight 0 is the base + let mut base = sdr.clone(); + apply(&mut base, w, h, &g, &m, 0.0).unwrap(); + for (a, b) in base.iter().zip(&sdr) { + assert!((a[0] - b[0]).abs() < 1e-5); + } + } + } + + #[test] + fn jpeg_round_trip_and_container_structure() { + let (w, h) = (96, 64); + let (sdr, hdr) = renditions(w, h); + let (g, m) = compute(&sdr, &hdr, w, h, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap(); + let base: Vec = + sdr.iter().flat_map(|p| p.map(|v| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8)).collect(); + let user_xmp = r#""#; + let meta = EncodeMeta { xmp: Some(user_xmp), ..EncodeMeta::default() }; + let file = encode_jpeg(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&base)), &g, &m, 90, ChromaSubsampling::S444, &meta).unwrap(); + assert!(is_gain_map_jpeg(&file)); + // the primary decodes as an ordinary JPEG of the right size, and keeps the user's XMP + let d = crate::decode(&file, crate::DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (w as u32, h as u32)); + let x = d.xmp.unwrap(); + assert!(x.contains("xmlns:dc=") && x.contains("hdrgm:Version") && x.contains("Item:Semantic=\"GainMap\"")); + // the MPF index points at the gain map, typed as one + let pm = jpeg::parse_markers(&file).unwrap(); + let e = jpeg::mpf_images(&pm); + assert_eq!(e.len(), 1); + assert_eq!(e[0].kind, jpeg::MPF_GAIN_MAP); + assert_eq!(e[0].offset + e[0].len, file.len()); + // and the gain map reads back + let r = read_jpeg(&file).unwrap(); + assert_eq!((r.gain.width, r.gain.height, r.gain.channels), (48, 32, 1)); + assert!((r.meta.max[0] - m.max[0]).abs() < 1e-5); + // the gain map is never a thumbnail + if let Some(t) = jpeg::embedded_thumbnail(&file, 32, 16) { + assert_ne!(t.source, crate::ThumbnailSource::MpfPreview); + } + } + + #[test] + fn garbage_never_panics() { + assert!(read_jpeg(b"").is_none()); + assert!(read_jpeg(&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x04, 0, 0]).is_none()); + assert!(!is_gain_map_jpeg(&[0xFF, 0xD8])); + let mut b = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 40]; + b.extend_from_slice(ISO_URN); + b.extend_from_slice(&[0, 0, 0, 0, 0xFF, 1, 2, 3, 4]); + assert!(read_jpeg(&b).is_none()); + let g = GainImage { width: 2, height: 2, channels: 1, data: vec![0; 3] }; + assert!(apply(&mut [[0.0; 3]; 4], 2, 2, &g, &GainMapMeta::default(), 1.0).is_err()); + assert!(compute(&[], &[], 0, 0, [0.3, 0.6, 0.1], &GainMapOptions::default()).is_err()); + } +} diff --git a/crates/codecs/src/heif.rs b/crates/codecs/src/heif.rs index fe91c0af5..702a0d27d 100644 --- a/crates/codecs/src/heif.rs +++ b/crates/codecs/src/heif.rs @@ -1,16 +1,18 @@ //! HEIF / HEIC (the iPhone and Mac photo format), read-only. //! //! Decoding lives in the optional `lightcraft-heif` crate (heic-rs, a pure-Rust HEVC -//! still-picture decoder), enabled by this crate's `heif` feature — off by default because HEVC -//! is patent-encumbered and whether a build carries an HEVC decoder is the distributor's call -//! (the same policy as the sibling PhotoCraft). Without the feature, HEIF files are still -//! detected and opening one is an [`Error::Unsupported`] error, never a panic. +//! still-picture decoder, plus a libheif-matching colour conversion), enabled by this crate's +//! `heif` feature — off by default because HEVC is patent-encumbered and whether a build carries +//! an HEVC decoder is the distributor's call (the same policy as the sibling PhotoCraft). Without +//! the feature, HEIF files are still detected and opening one is an [`Error::Unsupported`] error +//! saying so, never a panic. //! //! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF, //! so the decoder applies them and `orientation` is reported as 1: the EXIF Orientation tag only //! mirrors what the container says, and applying it as well would turn the photo twice. -//! The samples come back in the container's own primaries with its ICC profile (if any); like -//! every decoder here, the colour interpretation happens in [`crate::convert::finish`]. +//! The samples come back as 16-bit RGB in the container's own primaries, with its ICC profile +//! (iPhones: Display P3) or, without one, its `nclx` / VUI colour description; like every decoder +//! here, the colour interpretation happens in [`crate::convert::finish`]. #[cfg(feature = "heif")] use crate::convert::{Buf, Meta, Model, Raw, finish}; @@ -22,26 +24,32 @@ const F: Format = Format::Heif; #[cfg(not(feature = "heif"))] pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft"; -/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills -/// only; the wrapper names the exact construct in its own errors). -#[cfg(feature = "heif")] -const NOT_DECODED: &str = "the image is not a decodable HEVC still (image sequence, overlay, alpha-less grid or AVIF-coded item)"; - #[cfg(not(feature = "heif"))] pub(crate) fn decode(_bytes: &[u8], _opts: &DecodeOptions) -> Result { Err(Error::Unsupported(F, NOT_IN_BUILD)) } +#[cfg(not(feature = "heif"))] +pub(crate) fn header(_bytes: &[u8]) -> Result<(u32, u32, u16)> { + Err(Error::Unsupported(F, NOT_IN_BUILD)) +} + #[cfg(feature = "heif")] fn err(e: lightcraft_heif::Error) -> Error { match e { - // This crate's Unsupported takes a fixed reason; the dynamic wording lives in the - // lightcraft-heif error itself. - lightcraft_heif::Error::Unsupported(_) => Error::Unsupported(F, NOT_DECODED), + lightcraft_heif::Error::Unsupported(why) => Error::Unsupported(F, why), lightcraft_heif::Error::Limit(m) | lightcraft_heif::Error::Malformed(m) => Error::Malformed(F, m), } } +/// The displayed size (container transforms applied, so orientation 1), from the container +/// alone: no picture is decoded. +#[cfg(feature = "heif")] +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let info = lightcraft_heif::probe(bytes).map_err(err)?; + Ok((info.width, info.height, 1)) +} + #[cfg(feature = "heif")] pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { // The container alone: the declared size is checked before any pixel is decoded. @@ -50,16 +58,22 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { return Err(Error::TooLarge(info.width as u64, info.height as u64)); } let options = lightcraft_heif::Options { max_pixels: opts.max_pixels, apply_transforms: true }; - let decoded = lightcraft_heif::decode(bytes, &options).map_err(err)?; - // The decoder re-reads the container; trust its output's shape, not the probe's. - let buf = if decoded.sixteen_bit { - let mut v = Vec::with_capacity(decoded.data.len() / 2); - for pair in decoded.data.as_chunks::<2>().0 { - v.push(u16::from_ne_bytes(*pair)); + // A small decode (a thumbnail or a cataloguing probe) uses the file's own thumbnail image when + // it is at least as large as asked: iPhones store one of 320 × 240, and decoding it is a + // hundredth of the work of the 12 MP photo. + let thumb = match opts.max_size { + Some((mw, mh)) if mw > 0 && mh > 0 && mw.max(mh) < info.width.max(info.height) => { + lightcraft_heif::decode_thumbnail(bytes, mw.max(mh), &options).ok().flatten().filter(|t| { + // Only a thumbnail of the same picture: same orientation and aspect within 1 %. + let aspect = |w: u32, h: u32| w as f64 / h.max(1) as f64; + (aspect(t.width, t.height) / aspect(info.width, info.height) - 1.0).abs() < 0.01 + }) } - Buf::U16(v) - } else { - Buf::U8(decoded.data) + _ => None, + }; + let mut decoded = match thumb { + Some(t) => t, + None => lightcraft_heif::decode(bytes, &options).map_err(err)?, }; let raw = Raw { width: decoded.width as usize, @@ -67,11 +81,20 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { model: Model::Rgb, alpha: decoded.has_alpha, premultiplied: false, - buf, - bit_depth: if decoded.sixteen_bit { 16 } else { 8 }, + buf: Buf::U16(std::mem::take(&mut decoded.data)), + bit_depth: decoded.bit_depth, }; - let meta = Meta { icc: decoded.icc, exif: decoded.exif, xmp: decoded.xmp, orientation: Some(1), ..Default::default() }; - finish(F, raw, meta, (decoded.width, decoded.height), opts) + let c = decoded.colour; + let meta = Meta { + icc: decoded.icc, + exif: decoded.exif, + xmp: decoded.xmp, + orientation: Some(1), + // Used when there is no (usable) ICC profile. + hint: crate::space::SourceSpace::from_cicp(c.primaries, c.transfer), + ..Default::default() + }; + finish(F, raw, meta, (info.width, info.height), opts) } #[cfg(test)] @@ -96,6 +119,8 @@ mod tests { fn without_the_feature_heif_is_a_clear_unsupported_error() { let r = crate::decode(HEIC_HEADER, crate::DecodeOptions::default()); assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{r:?}"); + let h = crate::read_header(HEIC_HEADER); + assert!(matches!(&h, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("isn't included in this build")), "{h:?}"); } /// Found by PhotoCraft's `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice @@ -113,6 +138,7 @@ mod tests { fn a_heic_rs_panic_is_a_malformed_error() { let r = crate::decode(&HEIC_RS_BOX_PANIC, crate::DecodeOptions::default()); assert!(matches!(&r, Err(crate::Error::Malformed(Format::Heif, _))), "{r:?}"); + assert!(crate::read_header(&HEIC_RS_BOX_PANIC).is_err()); } #[cfg(feature = "heif")] @@ -120,6 +146,6 @@ mod tests { fn an_image_sequence_is_unsupported() { // An ftyp with no meta box: a video track, not a still. let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc", crate::DecodeOptions::default()); - assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, _))), "{r:?}"); + assert!(matches!(&r, Err(crate::Error::Unsupported(Format::Heif, why)) if why.contains("sequence")), "{r:?}"); } } diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index ec9e8efe3..c3de9edde 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec { write_matrix_trc(&n.rgb_space(), &n.trc()) } -fn linear_rec2020_profile() -> ColorProfile { +pub(crate) fn linear_rec2020_profile() -> ColorProfile { let mut p = ColorProfile::new_bt2020(); p.cicp = None; let lin = ToneReprCurve::Parametric(vec![1.0]); diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index 92409af35..e40cf7e73 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -25,6 +25,17 @@ pub(crate) struct Markers { pub adobe_transform: Option, /// Offset of the MPF TIFF header within the file and its bytes. pub mpf: Option<(usize, Vec)>, + /// The ISO 21496-1 APP2 payload after its URN: 4 bytes (versions only) on a primary image + /// that has a gain map, the full metadata on the gain map image itself. + pub iso_gainmap: Option>, +} + +impl Markers { + /// This image is a gain map (ISO 21496-1 metadata or Adobe `hdrgm` gain parameters), not a + /// picture: never a preview, never the image to show. + pub fn is_gain_map(&self) -> bool { + self.iso_gainmap.as_ref().is_some_and(|p| p.len() > 4) || self.xmp.as_deref().is_some_and(|x| x.contains("hdrgm:GainMapMax")) + } } pub(crate) fn parse_markers(b: &[u8]) -> Option { @@ -88,6 +99,10 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { icc_chunks.push((seg[12], &seg[14..])); } else if seg.starts_with(b"MPF\0") && m.mpf.is_none() { m.mpf = Some((start + 4, seg[4..].to_vec())); + } else if let Some(p) = seg.strip_prefix(crate::gainmap::ISO_URN) + && m.iso_gainmap.is_none() + { + m.iso_gainmap = Some(p.to_vec()); } } 0xEE if seg.starts_with(b"Adobe") && seg.len() >= 12 => { @@ -260,8 +275,11 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> if let (Some(ex), Some((o, l))) = (m.exif.as_deref(), summary.thumbnail) { candidates.push((&ex[o..o + l], ThumbnailSource::ExifThumbnail)); } - for (o, l) in mpf_images(&m) { - if let Some(s) = bytes.get(o..o.saturating_add(l)) { + for e in mpf_images(&m) { + if e.kind == MPF_GAIN_MAP { + continue; + } + if let Some(s) = bytes.get(e.offset..e.offset.saturating_add(e.len)) { candidates.push((s, ThumbnailSource::MpfPreview)); } } @@ -269,6 +287,10 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> let mut best: Option<(u32, &[u8], ThumbnailSource)> = None; for (data, src) in candidates { let Some(pm) = parse_markers(data) else { continue }; + // a gain map (also when its MPF entry doesn't say so) is greyscale gain data, not a preview + if pm.is_gain_map() { + continue; + } let long = pm.width.max(pm.height); // Previews must have the main image's aspect ratio (within 2%), else they are letterboxed/cropped. let aspect_ok = m.width > 0 @@ -290,8 +312,21 @@ pub(crate) fn embedded_thumbnail(bytes: &[u8], max_edge: u32, min_edge: u32) -> Some(Thumbnail { image: d.to_srgb8(), orientation, source, source_width: m.width, source_height: m.height }) } -/// (file offset, length) of MPF-listed images other than the primary. -fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { +/// MP type code of a gain map image (CIPA DC-007 entry attribute, low 24 bits). +pub(crate) const MPF_GAIN_MAP: u32 = 0x05_0000; + +/// An MPF-listed image other than the primary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MpfEntry { + /// File offset and length of the image. + pub offset: usize, + pub len: usize, + /// MP type code (attribute low 24 bits). + pub kind: u32, +} + +/// The MPF-listed images other than the primary. +pub(crate) fn mpf_images(m: &Markers) -> Vec { let Some((base, data)) = &m.mpf else { return vec![] }; let Some(t) = exif::Tiff::new(data) else { return vec![] }; let Some(ifd) = t.first_ifd() else { return vec![] }; @@ -307,14 +342,34 @@ fn mpf_images(m: &Markers) -> Vec<(usize, usize)> { let a = [rec[o], rec[o + 1], rec[o + 2], rec[o + 3]]; if data.starts_with(b"II") { u32::from_le_bytes(a) } else { u32::from_be_bytes(a) } }; - let (size, off) = (rd(4) as usize, rd(8) as usize); + let (attr, size, off) = (rd(0), rd(4) as usize, rd(8) as usize); if size > 0 && off > 0 { - out.push((base + off, size)); + out.push(MpfEntry { offset: base.saturating_add(off), len: size, kind: attr & 0x00FF_FFFF }); } } out } +/// Decode to the file's 8-bit samples as stored (no colour management): 1 channel for greyscale +/// files, else 3 (RGB). For data images such as gain maps, whose values are codes, not colours. +pub(crate) fn decode_codes8(bytes: &[u8]) -> Result<(usize, usize, usize, Vec)> { + let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; + if m.components == 0 { + return Err(Error::Malformed(F, "no frame header".into())); + } + check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?; + if !matches!(m.components, 1 | 3) || m.precision != 8 { + return Err(Error::Malformed(F, "expected an 8-bit greyscale or RGB image".into())); + } + let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; + let raw = if non_interleaved { decode_jpeg_decoder(bytes, &m, None)? } else { decode_zune(bytes, &m)? }; + let ch = raw.model.channels(); + match raw.buf { + Buf::U8(v) if v.len() >= raw.width * raw.height * ch => Ok((raw.width, raw.height, ch, v)), + _ => Err(Error::Malformed(F, "unexpected sample format".into())), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 169f40c11..657987189 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -17,8 +17,10 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod convert; +pub mod display; pub mod encode; pub mod exif; +pub mod gainmap; mod heif; pub mod icc; mod jpeg; @@ -34,7 +36,8 @@ mod tiff_codec; mod webp; pub use encode::{ - ChromaSubsampling, EncodeImage, EncodeMeta, Samples, TiffCompression, encode_avif, encode_jpeg, encode_png, encode_tiff, encode_webp_lossless, + ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, + encode_png, encode_tiff, encode_webp_lossless, }; pub use sniff::{Format, sniff}; pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; @@ -171,9 +174,9 @@ pub struct Header { } /// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with -/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from -/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small -/// decode. +/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP, PSD and HEIF are read +/// from their headers (markers, chunks, IFD, resources, boxes). GIF, BMP and JPEG XL fall back to +/// a small decode. /// /// The header readers also refuse what a decode would refuse before reaching the pixels (an /// unsupported layout, compression or frame type) and files whose image data is cut short @@ -196,6 +199,7 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result
{ Format::Tiff => tiff_codec::header(bytes)?, Format::WebP => webp::header(bytes)?, Format::Psd => psd::header(bytes)?, + Format::Heif => heif::header(bytes)?, // rare here (or, for JPEG XL, without a header reader yet): a small decode, as before _ => { let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?; @@ -217,6 +221,8 @@ pub fn decode_jpeg_with_fallback(bytes: &[u8], opts: DecodeOptions, fallback: Na #[doc(hidden)] pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result { let opts = *opts; + // The reason a format can't be decoded lives in one place, `Format::not_decodable`. + let unsupported = || Err(Error::Unsupported(format, format.not_decodable().unwrap_or("not decodable in this build"))); match format { Format::Jpeg => jpeg::decode(bytes, &opts), Format::Png => png_codec::decode(bytes, &opts), @@ -227,10 +233,9 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R #[cfg(feature = "jxl")] Format::Jxl => jxl::decode(bytes, &opts), #[cfg(not(feature = "jxl"))] - Format::Jxl => Err(Error::Unsupported(format, "built without the `jxl` feature")), + Format::Jxl => unsupported(), Format::Heif => heif::decode(bytes, &opts), - Format::Avif => Err(Error::Unsupported(format, "no pure-Rust, permissively licensed AV1 decoder yet")), - Format::RawTiffLike | Format::RawOther => Err(Error::Unsupported(format, "camera raw: decode with lightcraft-raw")), + Format::Avif | Format::RawTiffLike | Format::RawOther => unsupported(), } } diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs index 600f60b05..098f1cfaa 100644 --- a/crates/codecs/src/png_codec.rs +++ b/crates/codecs/src/png_codec.rs @@ -127,25 +127,10 @@ pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> { } fn container_hint(info: &png::Info) -> Option { - if let Some(c) = &info.coding_independent_code_points { - let named = match c.color_primaries { - 1 => Some(NamedSpace::Srgb), - 9 => Some(NamedSpace::Rec2020), - 12 => Some(NamedSpace::DisplayP3), - _ => None, - }; - let trc = match c.transfer_function { - 13 => Some(Trc::Srgb), - 1 | 6 | 14 | 15 => Some(Trc::Rec709), - 8 => Some(Trc::Linear), - 4 => Some(Trc::Gamma(2.2)), - _ => None, - }; - if let (Some(n), Some(t)) = (named, trc) { - let mut s = SourceSpace::named(n, SpaceOrigin::Container); - s.trc = Some([t.clone(), t.clone(), t]); - return Some(s); - } + if let Some(s) = + info.coding_independent_code_points.as_ref().and_then(|c| SourceSpace::from_cicp(c.color_primaries.into(), c.transfer_function.into())) + { + return Some(s); } if info.srgb.is_some() { return Some(SourceSpace::named(NamedSpace::Srgb, SpaceOrigin::Container)); diff --git a/crates/codecs/src/sniff.rs b/crates/codecs/src/sniff.rs index 50fa3ccf7..5bc5c4027 100644 --- a/crates/codecs/src/sniff.rs +++ b/crates/codecs/src/sniff.rs @@ -64,11 +64,20 @@ impl Format { /// Whether [`crate::decode`] can decode this format in this build. pub fn can_decode(self) -> bool { + self.not_decodable().is_none() + } + + /// Why [`crate::decode`] can't decode this format in this build (`None`: it can). The + /// [`crate::Error::Unsupported`] a decode returns carries the same reason. + pub fn not_decodable(self) -> Option<&'static str> { match self { - Format::Jxl => cfg!(feature = "jxl"), - Format::Heif => cfg!(feature = "heif"), - Format::Avif | Format::RawTiffLike | Format::RawOther => false, - _ => true, + #[cfg(not(feature = "jxl"))] + Format::Jxl => Some("built without the `jxl` feature"), + #[cfg(not(feature = "heif"))] + Format::Heif => Some(crate::heif::NOT_IN_BUILD), + Format::Avif => Some("no pure-Rust, permissively licensed AV1 decoder yet"), + Format::RawTiffLike | Format::RawOther => Some("camera raw: decode with lightcraft-raw"), + _ => None, } } diff --git a/crates/codecs/src/space.rs b/crates/codecs/src/space.rs index a945fe726..d8ee0d19f 100644 --- a/crates/codecs/src/space.rs +++ b/crates/codecs/src/space.rs @@ -255,6 +255,27 @@ impl SourceSpace { SourceSpace { named: Some(n), to_xyz_d50: n.to_xyz_d50(), trc: Some([t.clone(), t.clone(), t]), origin } } + /// The space named by ITU-T H.273 code points (PNG `cICP`, HEIF `nclx`, HEVC VUI), when + /// both the primaries and the transfer are ones this crate models. + pub(crate) fn from_cicp(primaries: u16, transfer: u16) -> Option { + let named = match primaries { + 1 => NamedSpace::Srgb, + 9 => NamedSpace::Rec2020, + 12 => NamedSpace::DisplayP3, + _ => return None, + }; + let trc = match transfer { + 13 => Trc::Srgb, + 1 | 6 | 14 | 15 => Trc::Rec709, + 8 => Trc::Linear, + 4 => Trc::Gamma(2.2), + _ => return None, + }; + let mut s = SourceSpace::named(named, SpaceOrigin::Container); + s.trc = Some([trc.clone(), trc.clone(), trc]); + Some(s) + } + /// Linear source RGB → linear `dst` RGB (Bradford from D50 to `dst`'s white). pub fn to_space(&self, dst: &RgbSpace) -> Mat3 { if let Some(n) = self.named { diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs index 15b236b0c..bfee84dfe 100644 --- a/crates/codecs/tests/fuzz.rs +++ b/crates/codecs/tests/fuzz.rs @@ -36,6 +36,17 @@ fn try_all(bytes: &[u8]) { let _ = read_header(bytes); let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) }); let _ = lightcraft_codecs::icc::parse(bytes); + let _ = lightcraft_codecs::gainmap::read_jpeg(bytes); + let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes); +} + +fn gain_map_jpeg(meta: &EncodeMeta) -> Vec { + use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg}; + let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect(); + let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect(); + let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap(); + let base: Vec = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect(); + encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap() } #[test] @@ -91,6 +102,7 @@ fn seeds() -> &'static Vec> { encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(), encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(), encode_webp_lossless(&e, &meta).unwrap(), + gain_map_jpeg(&meta), ] }) } @@ -106,7 +118,7 @@ proptest! { } #[test] - fn mutated_valid_files(which in 0usize..6, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) { + fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::(), any::()), 1..12), cut in any::()) { let mut b = seeds()[which].clone(); for (i, v) in flips { let i = i.index(b.len()); diff --git a/crates/codecs/tests/heif.rs b/crates/codecs/tests/heif.rs new file mode 100644 index 000000000..d14f51ac4 --- /dev/null +++ b/crates/codecs/tests/heif.rs @@ -0,0 +1,98 @@ +//! HEIC/HEIF through the codec API (feature `heif`), on lossless synthetic files from +//! `lightcraft_heif::testdata`: an iPhone-like grid with a Display P3 profile, rotation, EXIF, a +//! thumbnail and an HDR gain map; `nclx`-only colour; 10-bit; and hostile input. +#![cfg(feature = "heif")] + +use lightcraft_codecs::*; +use lightcraft_heif::testdata::{Prop, Spec, build}; + +fn grey(x: u32, y: u32) -> [u16; 3] { + [((x * 7 + y * 13) % 220 + 20) as u16, 128, 128] +} + +/// EXIF whose Orientation (6) only mirrors the container's `irot`: it must not turn the photo again. +fn exif_orientation_6() -> Vec { + let mut t = b"MM\0\x2a\0\0\0\x08\0\x01".to_vec(); + t.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 6, 0, 0]); + t.extend_from_slice(&[0, 0, 0, 0]); + t +} + +/// The thumbnail is flat mid grey so a test can tell it from the photo. +fn iphone_like() -> Vec { + build(&Spec { + props: vec![Prop::Icc(icc::write_named(NamedSpace::DisplayP3)), Prop::Irot(3)], + exif: Some(exif_orientation_6()), + thumbnail: Some(&|_, _| [126, 128, 128]), + ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap"], + ..Spec::new(96, 64, &grey) + }) +} + +#[test] +fn an_iphone_like_heic_decodes_upright_in_display_p3() { + let bytes = iphone_like(); + assert_eq!(sniff(&bytes), Some(Format::Heif)); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + // irot 3 = 90° clockwise, applied by the decoder; the EXIF Orientation is not applied again. + assert_eq!((d.width, d.height, d.source_width, d.source_height, d.orientation), (64, 96, 64, 96, 1)); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + assert_eq!(d.space.origin, SpaceOrigin::IccMatrixTrc); + assert_eq!((d.bit_depth, d.has_alpha, d.float), (8, false, false)); + assert!(d.exif.is_some() && d.icc.is_some()); + // No nclx and no VUI: limited range, BT.601; neutral chroma stays neutral. + let srgb = d.to_srgb8(); + let px = srgb.data[0]; + assert!(px[0].abs_diff(px[1]) <= 1 && px[1].abs_diff(px[2]) <= 1, "{px:?}"); + + let h = read_header(&bytes).unwrap(); + assert_eq!((h.format, h.width, h.height, h.orientation), (Format::Heif, 64, 96, 1)); +} + +#[test] +fn a_small_decode_uses_the_embedded_thumbnail() { + let bytes = iphone_like(); + // The 48 × 32 thumbnail, turned like the photo, covers a 32-pixel request. + let d = decode(&bytes, DecodeOptions::fit(32, 32)).unwrap(); + assert!(d.width.max(d.height) <= 32 && d.height > d.width, "{}x{}", d.width, d.height); + assert_eq!((d.source_width, d.source_height), (64, 96)); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + let flat = d.to_srgb8(); + assert!(flat.data.iter().all(|p| *p == flat.data[0]), "the flat thumbnail, not the photo"); + // A thumbnail too small for the request: the photo is decoded and resized. + let d = decode(&bytes, DecodeOptions::fit(60, 60)).unwrap(); + assert_eq!((d.width.max(d.height), d.source_width), (60, 64)); + let photo = d.to_srgb8(); + assert!(photo.data.iter().any(|p| *p != photo.data[0])); + let t = decode_thumbnail(&bytes, 32).unwrap(); + assert_eq!((t.source_width, t.source_height), (64, 96)); +} + +#[test] +fn nclx_colour_and_ten_bit() { + // Display P3 primaries, sRGB transfer, BT.709 matrix, full range: the nclx alone names the space. + let bytes = build(&Spec { bit_depth: 10, props: vec![Prop::Nclx(12, 13, 1, true)], ..Spec::new(64, 32, &|x, _| [(x * 16) as u16, 512, 512]) }); + let d = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + assert_eq!(d.space.origin, SpaceOrigin::Container); + assert_eq!(d.bit_depth, 10); + // Full-range 10-bit neutral steps of 16 code values: linear values rise monotonically. + let row: Vec = (0..64).map(|x| d.image.row(0)[x][1]).collect(); + assert!(row.windows(2).all(|w| w[1] > w[0]), "{row:?}"); +} + +#[test] +fn hostile_heic_is_an_error_never_a_panic() { + let bytes = iphone_like(); + let full = decode(&bytes, DecodeOptions::default()).unwrap(); + for cut in (12..bytes.len()).step_by(11) { + let b = &bytes[..cut]; + // Cut inside the photo: an error. Cut in a trailing item (gain map, thumbnail): the photo. + if let (Ok(_), Ok(d)) = (read_header(b), decode(b, DecodeOptions::default())) { + assert_eq!(d.image.data, full.image.data, "cut at {cut}"); + } + let _ = decode_thumbnail(b, 16); + } + let r = decode(&bytes, DecodeOptions { max_pixels: 64 * 96 - 1, ..Default::default() }); + assert!(matches!(r, Err(Error::TooLarge(64, 96))), "{r:?}"); +} diff --git a/crates/develop/src/controls.rs b/crates/develop/src/controls.rs index c8a001b79..83b26403d 100644 --- a/crates/develop/src/controls.rs +++ b/crates/develop/src/controls.rs @@ -140,6 +140,13 @@ controls! { "light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered; "light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered; "light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered; + "hdr.maxEv" => hdr.max_ev, "HDR Headroom", Light, 0, 5, 3, 0.1, 1, Plain; + "hdr.sdrBrightness" => hdr.sdr_brightness, "SDR Brightness", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrContrast" => hdr.sdr_contrast, "SDR Contrast", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrHighlights" => hdr.sdr_highlights, "SDR Highlights", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrShadows" => hdr.sdr_shadows, "SDR Shadows", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrWhites" => hdr.sdr_whites, "SDR Whites", Light, -100, 100, 0, 1, 0, Centered; + "hdr.sdrClarity" => hdr.sdr_clarity, "SDR Clarity", Light, -100, 100, 0, 1, 0, Centered; "curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered; "curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered; "curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered; diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index b7c952511..c2edd76c9 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -56,11 +56,13 @@ impl DevelopSettings { h } - /// True if nothing differs from a fresh default (ignoring white balance "as shot" values). + /// True if nothing differs from a fresh default (ignoring white balance "as shot" values, and + /// the rendering process: that is not an edit). pub fn is_unedited(&self) -> bool { let mut a = self.clone(); let d = DevelopSettings::default(); a.wb = d.wb; + a.process = d.process; a == d } @@ -130,6 +132,9 @@ impl DevelopSettings { } } +#[cfg(test)] +mod tests_process; + #[cfg(test)] mod tests { use super::*; @@ -162,6 +167,27 @@ mod tests { assert_ne!(back.hash64(), DevelopSettings::default().hash64()); } + #[test] + fn default_hdr_is_not_serialized_so_existing_hashes_stay() { + let s = DevelopSettings::default(); + assert!(s.to_json().get("hdr").is_none(), "HDR off must not change the settings JSON or hash64"); + let mut on = s.clone(); + on.hdr.enabled = true; + assert!(on.to_json().get("hdr").is_some()); + assert_ne!(on.hash64(), s.hash64()); + assert_eq!(DevelopSettings::from_json(&on.to_json()).unwrap(), on); + } + + #[test] + fn hdr_peak_is_finite_for_a_non_finite_headroom() { + let mut h = Hdr { enabled: true, max_ev: f64::NAN, ..Hdr::default() }; + assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2()); + h.max_ev = f64::INFINITY; + assert_eq!(h.peak(), (Hdr::DEFAULT_MAX_EV as f32).exp2()); + h.max_ev = -3.0; + assert_eq!(h.peak(), 1.0); + } + #[test] fn missing_fields_take_defaults_and_unknown_ignored() { let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index 64bb116ca..8bd150600 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -3,6 +3,10 @@ //! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it //! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current //! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]). +//! +//! The rendering process ([`crate::ProcessVersion`]) is in no group: copy, paste, sync and presets +//! made from a photo never carry it, so the photos they change keep their own. A partial that names +//! `process` itself (Apply Settings JSON) sets it like any other field. use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -98,7 +102,7 @@ impl SettingsGroup { SettingsGroup::Profile => &["profile"], SettingsGroup::Treatment => &["treatment"], SettingsGroup::WhiteBalance => &["wb"], - SettingsGroup::Light => &["light"], + SettingsGroup::Light => &["light", "hdr"], SettingsGroup::ToneCurve => &["curve"], SettingsGroup::Color => &["color"], SettingsGroup::ColorMixer => &["mixer", "bw_mix", "point_colors"], @@ -162,7 +166,9 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { } Value::Object(out) } - (Value::Number(b), Value::Number(p)) => { + // Integer fields (the grain seed, ids) are discrete like strings: an interpolated seed would + // not deserialize as an integer, and the whole preset would silently not apply. + (Value::Number(b), Value::Number(p)) if b.is_f64() || p.is_f64() => { let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0)); serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null) } @@ -267,6 +273,25 @@ mod tests { assert_eq!(lots.light.exposure, 5.0); } + /// A preset with the Grain group (its seed is an integer) used to do nothing at any amount + /// other than 100 %: the interpolated seed failed to deserialize and the preset was dropped. + #[test] + fn amount_keeps_integer_fields_whole() { + let mut a = DevelopSettings::default(); + a.light.exposure = 1.0; + a.grain.amount = 40.0; + a.grain.seed = 12345; + let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy()); + assert!(p.settings.pointer("/grain/seed").is_some_and(Value::is_u64), "the default copy groups include the grain seed"); + let base = DevelopSettings::default(); + for (amount, seed) in [(0.3, 0), (0.5, 12345), (1.5, 12345), (2.0, 12345)] { + let out = p.apply(&base, amount); + assert!((out.light.exposure - amount.min(5.0)).abs() < 1e-9, "{amount}: exposure {}", out.light.exposure); + assert!((out.grain.amount - (40.0 * amount).min(100.0)).abs() < 1e-9, "{amount}: grain {}", out.grain.amount); + assert_eq!(out.grain.seed, seed, "{amount}: seed"); + } + } + #[test] fn deep_merge_objects() { let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]}); diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 420112871..54577bffc 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -1,19 +1,118 @@ //! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so -//! older/newer files load (unknown fields are ignored, missing fields take defaults). +//! older/newer files load (unknown fields are ignored, missing fields take defaults). The one +//! exception is `process`: missing (or unreadable), it is V1, the rendering those older files were +//! made with. use lightcraft_geom::{CropGeometry, Homography, Orientation, Point}; use serde::{Deserialize, Serialize}; pub const SCHEMA_VERSION: u32 = 1; +/// The rendering process a photo's settings are interpreted by (what Lightroom calls the process +/// version). A change that would alter how existing settings render ships as a new process, so a +/// photo keeps its look until someone updates it (`develop.updateProcess`). See +/// `docs/process-versions.md`. +/// +/// Stored as a plain number: one written by a newer LightCraft loads, is kept as it is, and +/// renders with the newest process this build knows ([`ProcessVersion::process`]). Reading is +/// lenient: a whole number from 0 to 2^32 - 1 (also written as `2.0`) is that process; anything +/// else (negative, fractional, too large, text, null, a list…) reads as V1, like a missing field, +/// so a damaged value never costs the rest of the settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct ProcessVersion(pub u32); + +impl<'de> Deserialize<'de> for ProcessVersion { + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Wire { + Whole(u64), + Number(f64), + Other(serde::de::IgnoredAny), + } + Ok(match Wire::deserialize(d)? { + Wire::Whole(n) => u32::try_from(n).map_or_else(|_| ProcessVersion::legacy(), ProcessVersion), + Wire::Number(x) if x.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&x) => ProcessVersion(x as u32), + Wire::Number(_) | Wire::Other(_) => ProcessVersion::legacy(), + }) + } +} + +impl ProcessVersion { + /// The first process: LightCraft's rendering from before process versions existed. Frozen. + pub const V1: ProcessVersion = Process::V1.version(); + /// The process new photos and Reset get. + pub const LATEST: ProcessVersion = Process::LATEST.version(); + + /// The process of settings saved without one (catalogs, snapshots, XMP sidecars and browser + /// libraries written before process versions existed): V1, whatever [`Self::LATEST`] becomes. + pub fn legacy() -> ProcessVersion { + ProcessVersion::V1 + } + + /// Saved settings leave the field out for V1, so files and preview-cache keys of settings made + /// before process versions existed stay byte for byte what they were. + pub fn is_legacy(&self) -> bool { + *self == ProcessVersion::legacy() + } + + /// The process this build renders with: the newest one it knows that isn't newer than this + /// number (a number from a newer LightCraft renders with [`Process::LATEST`]; one below V1, + /// which no LightCraft writes, with V1). + pub fn process(self) -> Process { + Process::ALL.into_iter().rev().find(|p| p.version() <= self).unwrap_or(Process::V1) + } + + /// A process this build implements. + pub fn is_known(self) -> bool { + Process::ALL.iter().any(|p| p.version() == self) + } + + /// Older than [`Self::LATEST`]: Update to Current Process applies. + pub fn is_outdated(self) -> bool { + self < ProcessVersion::LATEST + } +} + +/// A rendering process this build implements. Matches on it are exhaustive, so adding a process +/// makes the compiler point at every stage whose behaviour depends on it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Process { + /// The rendering of LightCraft before process versions existed (2026-10). + V1, +} + +impl Process { + /// Every process, oldest first. + pub const ALL: [Process; 1] = [Process::V1]; + pub const LATEST: Process = Process::V1; + + pub const fn version(self) -> ProcessVersion { + match self { + Process::V1 => ProcessVersion(1), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct DevelopSettings { + /// Schema version of this structure (not the rendering process, see `process`). pub version: u32, + /// The rendering process ([`ProcessVersion`]): the latest for new settings, V1 for settings + /// saved without the field (the field-level default: not the struct's `Default`) or with a + /// value that isn't a process number. + #[serde(default = "ProcessVersion::legacy", skip_serializing_if = "ProcessVersion::is_legacy")] + pub process: ProcessVersion, pub profile: Profile, pub treatment: Treatment, pub wb: WhiteBalance, pub light: Light, + /// HDR editing: highlights above SDR white, and the SDR rendition derived from them. Not written while it is + /// the default, so settings without HDR keep their JSON and `hash64` (and every cached preview stays valid). + #[serde(skip_serializing_if = "Hdr::is_default")] + pub hdr: Hdr, pub curve: ToneCurve, pub color: ColorAdj, pub mixer: Mixer, @@ -44,10 +143,12 @@ impl Default for DevelopSettings { fn default() -> Self { Self { version: SCHEMA_VERSION, + process: ProcessVersion::LATEST, profile: Profile::default(), treatment: Treatment::Color, wb: WhiteBalance::default(), light: Light::default(), + hdr: Hdr::default(), curve: ToneCurve::default(), color: ColorAdj::default(), mixer: Mixer::default(), @@ -931,3 +1032,78 @@ impl Enhance { self.denoise_on.unwrap_or(self.denoise > 0.0) } } + +/// HDR editing. When enabled, an HDR render ([`Hdr::peak`]) keeps highlights above SDR white (1.0) +/// up to `2^max_ev`; every SDR render (previews on SDR displays, 8/16-bit exports and a gain map's +/// base image) uses the SDR rendition ([`DevelopSettings::sdr_rendition`]): the same edit with the +/// `sdr_*` offsets applied. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Hdr { + pub enabled: bool, + /// Headroom limit: stops above SDR white the HDR render may reach. + pub max_ev: f64, + /// SDR rendition offsets (−100..100): brightness (±1 EV), then added to the matching sliders. + pub sdr_brightness: f64, + pub sdr_contrast: f64, + pub sdr_highlights: f64, + pub sdr_shadows: f64, + pub sdr_whites: f64, + pub sdr_clarity: f64, +} + +impl Default for Hdr { + fn default() -> Self { + Self { + enabled: false, + max_ev: Hdr::DEFAULT_MAX_EV, + sdr_brightness: 0.0, + sdr_contrast: 0.0, + sdr_highlights: 0.0, + sdr_shadows: 0.0, + sdr_whites: 0.0, + sdr_clarity: 0.0, + } + } +} + +impl Hdr { + pub const DEFAULT_MAX_EV: f64 = 3.0; + pub const MAX_EV_LIMIT: f64 = 5.0; + + /// Whether these are the default HDR settings (HDR off, nothing changed). + pub fn is_default(&self) -> bool { + *self == Hdr::default() + } + + /// Peak linear value of an HDR render relative to SDR white (1.0 when HDR is off). A non-finite headroom + /// (a damaged file or argument) falls back to the default, so the peak is always finite and at least 1. + pub fn peak(&self) -> f32 { + if !self.enabled { + return 1.0; + } + let ev = if self.max_ev.is_finite() { self.max_ev } else { Hdr::DEFAULT_MAX_EV }; + (ev.clamp(0.0, Hdr::MAX_EV_LIMIT) as f32).exp2() + } +} + +impl DevelopSettings { + /// The settings an SDR render uses: unchanged when HDR is off; otherwise HDR off and the + /// SDR rendition offsets applied to exposure, contrast, highlights, shadows, whites, clarity. + pub fn sdr_rendition(&self) -> std::borrow::Cow<'_, DevelopSettings> { + if !self.hdr.enabled { + return std::borrow::Cow::Borrowed(self); + } + let h = self.hdr; + let mut s = self.clone(); + s.hdr.enabled = false; + let add = |v: f64, d: f64| (v + d).clamp(-100.0, 100.0); + s.light.exposure = (s.light.exposure + h.sdr_brightness / 100.0).clamp(-5.0, 5.0); + s.light.contrast = add(s.light.contrast, h.sdr_contrast); + s.light.highlights = add(s.light.highlights, h.sdr_highlights); + s.light.shadows = add(s.light.shadows, h.sdr_shadows); + s.light.whites = add(s.light.whites, h.sdr_whites); + s.effects.clarity = add(s.effects.clarity, h.sdr_clarity); + std::borrow::Cow::Owned(s) + } +} diff --git a/crates/develop/src/tests_process.rs b/crates/develop/src/tests_process.rs new file mode 100644 index 000000000..f89ebfa11 --- /dev/null +++ b/crates/develop/src/tests_process.rs @@ -0,0 +1,162 @@ +//! Process versions: settings saved before they existed stay on V1, new settings get the latest, +//! and numbers this build doesn't know load without harm. + +use serde_json::{Value, json}; + +use crate::*; + +/// Complete settings as LightCraft wrote them before process versions existed (main at 8f924a9: +/// `serde_json::to_string` of a raw photo with tone, colour, curve, grading, effects and a radial +/// mask), one top-level key per line. There is no `process` field. +const LEGACY: &str = r#" +{ + "version": 1, + "profile": {"id": "lc.color", "amount": 100.0}, + "treatment": "color", + "wb": {"mode": "asShot", "temp": 5200.0, "tint": 4.0}, + "light": {"exposure": 0.4, "contrast": 30.0, "highlights": -60.0, "shadows": 40.0, "whites": 15.0, "blacks": -20.0}, + "curve": {"highlights": -10.0, "lights": 0.0, "darks": 10.0, "shadows": 0.0, "split_shadows": 25.0, "split_mid": 50.0, "split_highlights": 75.0, "master": [{"x": 0.0, "y": 0.05}, {"x": 0.5, "y": 0.55}, {"x": 1.0, "y": 0.95}], "red": [], "green": [], "blue": [], "refine_saturation": 100.0}, + "color": {"vibrance": 20.0, "saturation": -5.0}, + "mixer": {"red": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "orange": {"hue": 0.0, "sat": 0.0, "lum": 15.0}, "yellow": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "green": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "aqua": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blue": {"hue": 0.0, "sat": -30.0, "lum": 0.0}, "purple": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "magenta": {"hue": 0.0, "sat": 0.0, "lum": 0.0}}, + "point_colors": [], + "bw_mix": {"red": 0.0, "orange": 0.0, "yellow": 0.0, "green": 0.0, "aqua": 0.0, "blue": 0.0, "purple": 0.0, "magenta": 0.0}, + "grading": {"shadows": {"hue": 220.0, "sat": 20.0, "lum": -5.0}, "midtones": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "highlights": {"hue": 40.0, "sat": 15.0, "lum": 5.0}, "global": {"hue": 0.0, "sat": 0.0, "lum": 0.0}, "blending": 50.0, "balance": 0.0}, + "effects": {"texture": 20.0, "clarity": 25.0, "dehaze": 15.0}, + "vignette": {"amount": -25.0, "midpoint": 50.0, "roundness": 0.0, "feather": 50.0, "highlights": 0.0, "style": "highlightPriority"}, + "grain": {"amount": 20.0, "size": 25.0, "roughness": 50.0, "seed": 0}, + "detail": {"sharpen_amount": 40.0, "sharpen_radius": 1.0, "sharpen_detail": 25.0, "sharpen_masking": 0.0, "nr_luminance": 20.0, "nr_detail": 50.0, "nr_contrast": 0.0, "nr_color": 25.0, "nr_color_detail": 50.0, "nr_color_smoothness": 50.0}, + "optics": {"remove_ca": false, "lens_profile": false, "profile_distortion": 100.0, "profile_vignetting": 100.0, "defringe_purple_amount": 0.0, "defringe_purple_hue_lo": 30.0, "defringe_purple_hue_hi": 70.0, "defringe_green_amount": 0.0, "defringe_green_hue_lo": 40.0, "defringe_green_hue_hi": 60.0, "distortion": 0.0, "vignetting": 0.0, "vignetting_midpoint": 50.0, "ca_red": 0.0, "ca_blue": 0.0}, + "geometry": {"upright": "off", "guides": [], "upright_transform": null, "vertical": 0.0, "horizontal": 0.0, "rotate": 0.0, "aspect": 0.0, "scale": 100.0, "offset_x": 0.0, "offset_y": 0.0, "constrain_crop": false}, + "crop": {"geometry": {"rect": {"x0": 0.0, "y0": 0.0, "x1": 1.0, "y1": 1.0}, "angle": 0.0}, "aspect": null, "flip_h": false, "flip_v": false}, + "orientation": "Normal", + "masks": [{"id": 1, "name": "Mask 1", "visible": true, "invert": false, "components": [{"op": "add", "invert": false, "shape": {"kind": "radial", "center": {"x": 0.4, "y": 0.5}, "rx": 0.3, "ry": 0.2, "angle": 15.0, "feather": 60.0, "invert": false}}], "adjust": {"temp": 10.0, "tint": 0.0, "exposure": 0.5, "contrast": 0.0, "highlights": 0.0, "shadows": 0.0, "whites": 0.0, "blacks": 0.0, "texture": 0.0, "clarity": 30.0, "dehaze": 0.0, "hue": 0.0, "saturation": 0.0, "sharpness": 0.0, "noise": 0.0, "moire": 0.0, "defringe": 0.0, "color_hue": 0.0, "color_sat": 0.0, "amount": 100.0}}], + "spots": [], + "red_eye": [], + "lens_blur": {"enabled": false, "amount": 0.0, "focal_distance": 0.0, "focal_range": 0.0}, + "enhance": {"denoise": 0.0, "raw_details": false, "super_resolution": false}, + "calibration": {"shadows_tint": 0.0, "red_hue": 0.0, "red_sat": 0.0, "green_hue": 0.0, "green_sat": 0.0, "blue_hue": 0.0, "blue_sat": 0.0}, + "disabled_sections": [] +} +"#; + +fn legacy() -> Value { + serde_json::from_str(LEGACY).unwrap() +} + +#[test] +fn settings_saved_before_process_versions_load_as_v1() { + let v = legacy(); + assert!(v.get("process").is_none(), "the fixture predates the field"); + let s = DevelopSettings::from_json(&v).unwrap(); + // V1 as a number, not `LATEST`: once a newer process is the latest, this is what keeps every + // existing catalog, sidecar and preset on the rendering it was made with + assert_eq!(s.process, ProcessVersion(1)); + assert_eq!(s.process, ProcessVersion::legacy()); + assert_eq!((s.light.exposure, s.light.highlights, s.masks.len(), s.detail.sharpen_amount), (0.4, -60.0, 1, 40.0)); + // the smallest legacy file too, and settings nested in other records (history, versions) + assert_eq!(DevelopSettings::from_json(&json!({})).unwrap().process, ProcessVersion(1)); + let nested: Vec = serde_json::from_value(json!([{}, {"light": {"exposure": 1.0}}])).unwrap(); + assert!(nested.iter().all(|s| s.process == ProcessVersion(1))); + // saved again, they gain no field: files and preview-cache keys (`hash64`) stay as they were + assert!(s.to_json().get("process").is_none()); +} + +#[test] +fn new_settings_get_the_latest_process() { + assert_eq!(DevelopSettings::default().process, ProcessVersion::LATEST); + assert_eq!(DevelopSettings::for_raw(5200.0, 4.0).process, ProcessVersion::LATEST); + assert_eq!(ProcessVersion::LATEST, Process::LATEST.version()); + assert_eq!(ProcessVersion::V1, ProcessVersion(1)); + // the processes are listed oldest first, the latest last, each with its own number + let versions: Vec = Process::ALL.iter().map(|p| p.version().0).collect(); + assert!(versions.windows(2).all(|w| w[0] < w[1]), "{versions:?}"); + assert_eq!(Process::ALL.last(), Some(&Process::LATEST)); + assert!(Process::ALL.iter().all(|p| p.version().process() == *p && p.version().is_known())); +} + +#[test] +fn v1_is_left_out_when_saving_and_other_processes_are_kept() { + let mut s = DevelopSettings::from_json(&legacy()).unwrap(); + assert!(s.to_json().get("process").is_none()); + let before = s.hash64(); + s.process = ProcessVersion(2); + let v = s.to_json(); + assert_eq!(v["process"], json!(2)); + assert_eq!(DevelopSettings::from_json(&v).unwrap().process, ProcessVersion(2)); + assert_ne!(s.hash64(), before, "another process is another preview"); +} + +#[test] +fn unknown_process_numbers_load_and_render_with_the_newest_known() { + // written by a newer LightCraft: kept as it is, rendered with the latest process this build has + let s = DevelopSettings::from_json(&json!({"process": 7, "light": {"exposure": 0.5}})).unwrap(); + assert_eq!(s.process, ProcessVersion(7)); + assert_eq!(s.process.process(), Process::LATEST); + assert!(!s.process.is_known()); + assert!(!s.process.is_outdated(), "never downgraded by Update to Current Process"); + assert_eq!(s.to_json()["process"], json!(7)); + assert_eq!(s.merged(&json!({"light": {"exposure": 1.0}})).unwrap().process, ProcessVersion(7)); + // below V1 (no LightCraft writes it): rendered as V1, and updatable + let zero = DevelopSettings::from_json(&json!({"process": 0})).unwrap().process; + assert_eq!((zero.process(), zero.is_known(), zero.is_outdated()), (Process::V1, false, true)); + let max = DevelopSettings::from_json(&json!({"process": u32::MAX})).unwrap().process; + assert_eq!(max.process(), Process::LATEST); + // a whole number written as a float is that number + assert_eq!(DevelopSettings::from_json(&json!({"process": 2.0})).unwrap().process, ProcessVersion(2)); +} + +#[test] +fn a_damaged_process_value_reads_as_v1_and_keeps_the_edits() { + let too_big = u64::from(u32::MAX) + 1; + for bad in [ + json!(-1), + json!(1.5), + json!(-0.5), + json!(1e300), + json!(too_big), + json!(u64::MAX), + json!("2"), + json!(null), + json!(true), + json!([2]), + json!({"v": 2}), + ] { + let mut v = legacy(); + v["process"] = bad.clone(); + let s = DevelopSettings::from_json(&v).unwrap_or_else(|e| panic!("{bad}: {e}")); + assert_eq!(s.process, ProcessVersion::V1, "{bad}"); + assert_eq!((s.light.exposure, s.masks.len(), s.curve.master.len()), (0.4, 1, 3), "{bad}: the rest of the settings load"); + // nested in other records too (history, versions, the catalog) + let nested: Vec = serde_json::from_value(json!([v])).unwrap(); + assert_eq!(nested[0].process, ProcessVersion::V1); + } +} + +#[test] +fn the_process_is_not_an_edit_nor_part_of_copied_settings() { + let mut s = DevelopSettings { process: ProcessVersion(LATEST_PLUS_ONE), ..Default::default() }; + assert!(s.is_unedited(), "a photo on another process is not edited for that"); + s.light.exposure = 1.0; + assert!(!s.is_unedited()); + // resetting a section or a slider leaves the process alone + s.reset_section(Section::Light); + assert_eq!(s.process, ProcessVersion(LATEST_PLUS_ONE)); + // copy / paste / sync / presets: no group carries the process, so the target keeps its own + assert!(SettingsGroup::ALL.iter().all(|g| !g.keys().contains(&"process"))); + s.light.exposure = 1.0; + let copied = extract_groups(&s, &SettingsGroup::ALL); + assert!(copied.get("process").is_none()); + let target = DevelopSettings::from_json(&legacy()).unwrap(); + let pasted = apply_partial(&target, &copied, 1.0); + assert_eq!((pasted.process, pasted.light.exposure), (ProcessVersion(1), 1.0)); + // (at another amount, without Grain: its integer seed doesn't survive scaling, a separate issue) + let groups: Vec = SettingsGroup::ALL.into_iter().filter(|g| *g != SettingsGroup::Grain).collect(); + let preset = Preset::from_settings("p", "P", "User", &s, &groups); + let half = preset.apply(&target, 0.5); + assert_eq!((half.process, half.light.exposure), (ProcessVersion(1), 0.7)); + // a partial that names a process explicitly (Apply Settings JSON) sets it + assert_eq!(target.merged(&json!({"process": LATEST_PLUS_ONE})).unwrap().process, ProcessVersion(LATEST_PLUS_ONE)); +} + +/// A process other than the latest: a newer LightCraft's number (no older one than V1 exists). +const LATEST_PLUS_ONE: u32 = ProcessVersion::LATEST.0 + 1; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 9bd9845fa..4036a5c94 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -49,5 +49,7 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"] [dev-dependencies] +# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on). +lightcraft-heif = { workspace = true, features = ["testdata"] } libc = { workspace = true } lightcraft-tiff = { workspace = true } diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 55dc2b11d..6a473a2de 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -247,18 +247,20 @@ fn main() { } } if run("batch") && args.len() > 1 { - // full-size JPEG export of all the files given (decode + render + encode) - use lightcraft_engine::export::export_photo; + // full-size JPEG export of all the files given (decode + render + encode), as the Export + // dialog runs it: several photos side by side (`export_parallelism`), files discarded + use lightcraft_engine::export::{Destination, export_batch}; let mut session = lightcraft_engine::Session::new().with_fs(); let r = session.execute("library.import", &serde_json::json!({"paths": args})).expect("import"); let ids: Vec<_> = r["imported"].as_array().expect("ids").iter().filter_map(|v| v.as_u64()).map(lightcraft_engine::catalog::PhotoId).collect(); - let items: Vec<_> = ids.iter().enumerate().map(|(i, id)| (*id, i + 1)).collect(); let o = ExportOptions::default(); - row(&format!("batch export of {} files (decode+render+JPEG):", items.len()), &mut |g| { + let lanes = lightcraft_engine::export::export_parallelism(ids.len()); + row(&format!("batch export of {} files, {lanes} at a time (decode+render+JPEG):", ids.len()), &mut |g| { lightcraft_engine::gpu::set_enabled(g); - for &(id, seq) in &items { - drop(export_photo(&mut session, id, &o, seq).expect("export")); - session.media.forget(id); // as in a batch: every original is decoded once + let files = export_batch(&mut session, &ids, &o, &Destination::default(), &mut |_, _| Ok(()), &|_| false).expect("export"); + assert_eq!(files.len(), ids.len()); + for &id in &ids { + session.media.forget(id); // every original is decoded once per run } }); lightcraft_engine::gpu::set_enabled(true); diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index 582580217..8f7789591 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1,15 +1,14 @@ //! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF, Panasonic -//! RW2, Fujifilm RAF, Canon CR3) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, +//! RW2, Fujifilm RAF, Canon CR2/CR3, Pentax PEF, Samsung SRW, Olympus ORF) from its own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only, //! never output pixels or a replacement for RAW editing. //! A global matrix can't follow the camera's hue-dependent rendering (the best matrix rendered a //! lime shirt olive that the camera kept lime): a hue/saturation table fitted to the residuals //! (applied like a DNG `ProfileHueSatMap`) corrects that when it also improves the held-out pixels. -use crate::camera_profiles::CameraProfile; use lightcraft_color::{D50, D65, Mat3, PROPHOTO, REC2020, Xy, bradford, luminance_2020}; use lightcraft_pipeline::tone::{CameraTone, ToneMap}; use lightcraft_raster::{ Rgb32f, - resample::{Filter, fit}, + resample::{Filter, fit, resize}, }; use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform, profile::HsvTable}; @@ -43,29 +42,39 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { | RawFormat::Cr2 | RawFormat::Pef | RawFormat::Srw + | RawFormat::Orf ) } -/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); the camera profile for -/// its model (bundled or local, `camera_profiles`), with tone and chroma fitted to the file's JPEG; the model's colour -/// matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); a fit to the file's own JPEG; the -/// neutral fallback. The spectral matrices are written into `raw.color`. Returns the as-shot white, the camera -/// transform at it and the fitted look. +/// The colour a raw starts from, first available first: the file's own colour matrices (DNG); a look fitted to the +/// file's JPEG ([`fit_preview`]: with the camera profile for its model when there is one, else with colour fitted to +/// this photo alone); the model's colour matrices from measured spectral sensitivities (`lightcraft_raw::spectral`); +/// the neutral fallback. The spectral matrices only stand in for the neutral fallback, so a photo whose JPEG fit is +/// accepted renders as without them; when used they are written into `raw.color`. 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)) +} + +/// [`starting_colour`] with the JPEG fit passed in (tests pin the order with it). +fn starting_colour_with( + raw: &mut RawImage, + fit: impl FnOnce(&RawImage, &CameraTransform) -> Option, +) -> (Xy, CameraTransform, Option) { let start = |raw: &RawImage| { let xy = lightcraft_raw::color::as_shot_white_xy(raw); (xy, lightcraft_raw::color::camera_transform(raw, xy)) }; let (xy, t) = start(raw); - let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); - let spectral = match raw.metadata.model.as_deref() { - Some(model) if t.matrix_is_fallback => lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model), - _ => None, - }; - let Some(camera) = spectral else { return (xy, t, fit_preview(raw, bytes, &t, profile.as_deref(), true)) }; - if let Some(look) = fit_preview(raw, bytes, &t, profile.as_deref(), false) { + if !t.matrix_is_fallback { + return (xy, t, None); + } + if let Some(look) = fit(raw, &t) { return (xy, t, Some(look)); } + let Some(camera) = raw.metadata.model.as_deref().and_then(|model| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), model)) else { + return (xy, t, None); + }; camera.fill(&mut raw.color); if lightcraft_pipeline::profiling() { eprintln!("[profile] {:?} camera colour from spectral sensitivities: {} {}", raw.format, camera.make, camera.model); @@ -74,27 +83,23 @@ pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTr (xy, t, None) } -/// The look fitted to the file's JPEG: with `profile`'s colour when there is one, else (or when that fails the -/// gates, if `own_colour`) with colour fitted to this photo alone. -fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, profile: Option<&CameraProfile>, own_colour: bool) -> Option { - if !transform.matrix_is_fallback || !file_local_look(raw.format) || (profile.is_none() && !own_colour) { +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option { + if !transform.matrix_is_fallback || !file_local_look(raw.format) { return None; } + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; // 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 (DRO and picture styles vary). - let colour = profile.and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); - if colour.is_none() && !own_colour { - return None; - } - let (sensor, reference) = proxies(raw, bytes, transform, PROXY)?; - let look = if own_colour { - fit_pairs_with(&sensor, &reference, colour)? - } else { - TONE_FITS.iter().find_map(|&tone| fit_candidate(&sensor, &reference, colour.clone(), tone))? - }; + // try its colour first and fit tone/chroma per photo (picture styles vary). + 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 look = fit_look(&sensor, &reference, &clipped, colour)?; + // Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions, + // not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO. + let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true); + let look = if dro { with_dro_off_tone(look) } else { look }; if lightcraft_pipeline::profiling() { eprintln!( - "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}", + "[profile] {:?} camera look: {:?}, {:?}, hue/sat table {}, camera profile available {}, DRO tone replaced {dro}", raw.format, look.matrix.0, look.tone, @@ -105,9 +110,129 @@ fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, profil Some(look) } +/// Sensor level (normalised, white = 1) at or above which a raw channel counts as clipped, as in +/// the render's highlight reconstruction. +const SENSOR_CLIP: f32 = 0.99; + +/// The photo's look, refitted without the proxy pixels whose sensor values are clipped. +/// +/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip +/// level while the others keep rising, a white overcast sky comes out magenta or blue through the +/// camera matrix, while the camera JPEG shows it white. As correspondences such pixels teach the +/// colour matrix to pull colours toward grey, and the chroma curve to remove colour at their +/// display luminance (on a Z 6II forest under a clipped sky the curve cut colourfulness above +/// display luminance 0.4 to 13–19%, and every one of its samples there was clipped). The render +/// rebuilds clipped highlights separately (`lightcraft_raw::highlight`), so the look is fitted on +/// what the sensor measured. +/// +/// The search on all pixels decides, as before, whether the photo gets a look and which attempt +/// of [`search_ordered`] gives it; only that attempt is refitted without the clipped pixels, and +/// when the refit misses the acceptance gates the look on all pixels is kept. So no photo loses +/// its look and none switches attempt (a refit that just clears the gate on all pixels must not +/// replace a look fitted away from edges). A photo that had no look gets one when the search +/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly +/// closer to the camera JPEG than the neutral fallback. +fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option)>) -> Option { + let unclipped = without_clipped(sensor, clipped); + let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else { + return unclipped + .as_ref() + .and_then(|u| search_ordered(u, reference, colour, TONE_FITS)) + .map(|(look, _)| look) + .or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference)); + }; + let Some(unclipped) = unclipped else { return Some(look) }; + let refit = fit_attempt(&unclipped, reference, &colour, attempt); + if refit.is_none() && lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels"); + } + Some(refit.unwrap_or(look)) +} + +/// `sensor` with its clipped pixels set to NaN (left out of every training pair), or `None` when +/// none is clipped. +fn without_clipped(sensor: &Rgb32f, clipped: &[bool]) -> Option { + if clipped.len() != sensor.data.len() || !clipped.iter().any(|c| *c) { + return None; + } + let mut out = sensor.clone(); + for (p, c) in out.data.iter_mut().zip(clipped) { + if *c { + *p = [f32::NAN; 3]; + } + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look: {} of {} proxy pixels have a clipped sensor channel", + clipped.iter().filter(|c| **c).count(), + clipped.len() + ); + } + Some(out) +} + +/// 1 where any channel of a camera-RGB pixel is at or above [`SENSOR_CLIP`], else 0. +fn clip_mask(camera: &Rgb32f) -> Rgb32f { + let mut mask = camera.clone(); + mask.map_in_place(|p| if p.iter().any(|v| *v >= SENSOR_CLIP) { [1.0; 3] } else { [0.0; 3] }); + mask +} + +/// Sony's global tone curve with the Dynamic Range Optimizer off (Standard creative style), pooled +/// from 12 CC0 raw.pixls.us ARWs of 10 interchangeable-lens bodies and their camera JPEGs; the file +/// lists them and how the curve was derived (`assets/ATTRIBUTION.md`). Pooled the same way from 55 +/// private ILCE-7CR photos with DRO off, the curve differs by at most 1.2 L* between scene luminance +/// 0.01 and 0.5. Issue #244, `docs/camera-preview-colour.md`. +const SONY_DRO_OFF_TONE: &str = include_str!("../../../assets/camera-tone/sony-dro-off.json"); + +/// [`SONY_DRO_OFF_TONE`]'s curve (`None` only if the built-in file were invalid; a test checks it). +fn sony_dro_off_tone() -> Option { + #[derive(serde::Deserialize)] + struct File { + knots: [[f32; 2]; 32], + } + static TONE: std::sync::OnceLock> = std::sync::OnceLock::new(); + *TONE.get_or_init(|| serde_json::from_str::(SONY_DRO_OFF_TONE).ok().and_then(|f| CameraTone::new(f.knots))) +} + +/// `look`, fitted to a camera JPEG brightened by Sony's Dynamic Range Optimizer, with its tone +/// curve lowered to Sony's without DRO ([`SONY_DRO_OFF_TONE`]) wherever that is darker. DRO only +/// brightens, so the fitted curve bounds the curve without it from above, everywhere: where Sony's +/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps +/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted. +fn with_dro_off_tone(look: CameraLook) -> CameraLook { + let Some(dro_off) = sony_dro_off_tone() else { return look }; + let own = look.tone.knots(); + let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]); + // Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots + // (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the + // extended highlights would end up above the fitted curve. Raise the second-last knot (never + // above the fitted curve) until the rate is at most the fitted curve's. + let ([a, b], last) = ([own[30], own[31]], knots[31]); + let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01); + let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]); + knots[30][1] = knots[30][1].max(floor).min(last[1]); + let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look }; + if !at_most(&tone, &look.tone) { + return look; + } + CameraLook { tone, ..look } +} + +/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone +/// table covers ([`ToneMap::camera`]). +fn at_most(a: &CameraTone, b: &CameraTone) -> bool { + use lightcraft_pipeline::tone::{GREY, LUT_MAX_EV, LUT_MIN_EV, LUT_N}; + (0..LUT_N).all(|i| { + let x = GREY * 2f32.powf(LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32); + a.apply(x) <= b.apply(x) + 1e-5 + }) +} + /// 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). -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f)> { +/// 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)> { 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: 64_000_000 })?; let mut reference = decoded.to_working(); @@ -132,23 +257,38 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz // Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution. 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); 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 { // Orient the camera JPEG and the sensor identically before collecting pixel correspondences. sensor = sensor.into_oriented(raw.orientation); + clipped = clipped.into_oriented(raw.orientation); reference = reference.into_oriented(raw.orientation); sensor.map_in_place(to_working); // Canon's JPEG can be cropped differently from the sensor. Estimate only that common // framing from edge directions, independently of the subsequent colour fit. - sensor = align_cr3_framing(sensor, &reference); + (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); } let mut sensor = fit(&sensor, size, size, Filter::Box); - let reference = fit(&reference, sensor.width, sensor.height, Filter::Box); + let reference = reference_at(&reference, &sensor); + // 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. + let clipped: Vec = resize(&clipped, sensor.width, sensor.height, Filter::Box).data.iter().map(|p| p[0] > 0.0).collect(); if raw.format != RawFormat::Cr3 { sensor.map_in_place(to_working); } - Some((sensor, reference)) + Some((sensor, reference, clipped)) +} + +/// The camera JPEG at exactly the sensor proxy's size, so the two pair pixel for pixel. Fitting it +/// into the proxy's box instead keeps its own aspect, which can round a pixel short: an ILCE-7RM4's +/// 1616×1080 preview, decoded at 384×257, fits a 192×128 proxy as 191×128, and `collect_pairs` +/// then rejected every file `lightcraft-cli calibrate` was given. `proxies` has already checked +/// that the aspects agree within 2 %. +fn reference_at(reference: &Rgb32f, sensor: &Rgb32f) -> Rgb32f { + resize(reference, sensor.width, sensor.height, Filter::Box) } #[derive(Clone, Copy, Debug)] @@ -275,15 +415,19 @@ fn estimate_cr3_framing(sensor: &Rgb32f, reference: &Rgb32f) -> Option Rgb32f { - let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return sensor }; - let (w, h) = (sensor.width, sensor.height); - let mut aligned = Rgb32f::new(w, h); - for (i, pixel) in aligned.data.iter_mut().enumerate() { - let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); - *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { sensor.sample_bilinear(x, y) } else { [f32::NAN; 3] }; - } - aligned +/// The sensor proxy and its clip mask (same size), both moved to the camera JPEG's framing. +fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rgb32f, Rgb32f) { + let Some(framing) = estimate_cr3_framing(&sensor, reference) else { return (sensor, clipped) }; + let apply = |image: &Rgb32f| { + let (w, h) = (image.width, image.height); + let mut aligned = Rgb32f::new(w, h); + for (i, pixel) in aligned.data.iter_mut().enumerate() { + let (x, y) = framing.source((i % w) as f32 + 0.5, (i / w) as f32 + 0.5, w, h); + *pixel = if x >= 0.5 && y >= 0.5 && x <= w as f32 - 0.5 && y <= h as f32 - 0.5 { image.sample_bilinear(x, y) } else { [f32::NAN; 3] }; + } + aligned + }; + (apply(&sensor), apply(&clipped)) } /// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the @@ -294,8 +438,10 @@ pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option Option<(Mat3, Optio } fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option { - Some(match raw.develop_binned(k, 0.99).ok()? { + Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box), None => return None, @@ -434,45 +580,66 @@ fn fit_matrix(pairs: &[([f64; 3], [f64; 3])]) -> Option { } /// Neighbourhood luminance ratio above which a pixel counts as an edge for the second attempt -/// of [`fit_pairs_with`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", +/// of [`search_ordered`]. Camera JPEGs are often lens-corrected (Sony "Distortion Comp.: Auto", /// compacts and kit zooms): on a 51 mm ILCE-7RM2 shot of a glass façade the JPEG is up to 15 px /// of 1440 (about one proxy pixel) off the raw, and the fit failed the gate on the mismatched /// window frames alone (held-out RMS 0.111; 0.059 for the same shot with distortion correction /// off). Away from edges: 0.049. Issue #232. const EDGE_CONTRAST: f32 = 3.0; -/// The photo's look: its own matrix (with and without a hue/saturation table), or the given -/// colour model (a camera profile's, in the sensor proxy's space), each completed with a tone -/// and chroma curve fitted to this photo. When the fit on all pixels fails the acceptance gates, -/// it is tried once more on the pixels away from edges (same gates): there colour pairs stay -/// valid when the camera JPEG's geometry differs slightly from the raw's. +/// The look on all pixels (the search of [`search_ordered`] with [`TONE_FITS`]), as before +/// clipped pixels were left out. +#[cfg(test)] fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>) -> Option { - fit_pairs_ordered(sensor, reference, colour, &TONE_FITS) + fit_pairs_ordered(sensor, reference, colour, TONE_FITS) } -/// The tone fits a look tries, in order (see [`fit_pairs_ordered`]). -const TONE_FITS: [ToneFit; 2] = [ToneFit::Quantile, ToneFit::ConditionalMedian]; - -/// [`fit_pairs_with`] with the tone fits to try, in order: the first whose look passes the -/// acceptance gates is used. Quantile matching comes first because it cannot produce flat steps; -/// the conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) -/// its slightly lower held-out error is what clears the gates, and a photo must not lose its look -/// to the change of fit. The whole search (all pixels, then away from edges, with and without the -/// camera profile's colour) runs with one tone fit before the next is tried, so the fallback -/// accepts exactly what the previous releases accepted. +/// The look from [`search_ordered`] without its attempt. +#[cfg(test)] fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option { - let has_profile = colour.is_some(); + search_ordered(sensor, reference, colour, order).map(|(look, _)| look) +} + +/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is +/// used. Quantile matching comes first because it cannot produce flat steps; the +/// conditional-median fit follows because on a few photos (compacts, early Micro Four Thirds) its +/// slightly lower held-out error is what clears the gates, and a photo must not lose its look to +/// the change of fit. +const TONE_FITS: &[ToneFit] = &[ToneFit::Quantile, ToneFit::ConditionalMedian]; + +/// One attempt of the search: tone fit, the camera profile's colour or the photo's own, all pixels +/// or only those away from edges. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Attempt { + tone: ToneFit, + profile: bool, + away_from_edges: bool, +} + +/// The photo's look and the attempt that produced it: its own matrix (with and without a +/// hue/saturation table), or the given colour model (a camera profile's, in the sensor proxy's +/// space), each completed with a tone and chroma curve fitted to this photo. Per tone fit in +/// `order`: the camera profile's colour (when there is one), then the photo's own, as a different +/// picture style can make a camera profile fail the gates; each fitted on all pixels and, when that +/// fails the gates, once more on the pixels away from edges (same gates): there colour pairs stay +/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole +/// search runs with one tone fit before the next is tried, so the conditional-median fallback +/// accepts exactly what the previous releases accepted. +fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> { + let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] }; order.iter().find_map(|&tone| { - // A different picture style can make a camera profile fail the gates. Preserve the - // photo's own colour fit before resorting to the neutral fallback. - fit_candidate(sensor, reference, colour.clone(), tone).or_else(|| has_profile.then(|| fit_candidate(sensor, reference, None, tone)).flatten()) + profiles.iter().find_map(|&profile| { + [false, true].into_iter().find_map(|away_from_edges| { + let attempt = Attempt { tone, profile, away_from_edges }; + fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) + }) + }) }) } -/// One candidate colour model, fitted on all pixels and, when that fails the gates, once more on -/// the pixels away from edges (issue #232). -fn fit_candidate(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option)>, tone: ToneFit) -> Option { - fit_pairs_on(sensor, reference, colour.clone(), None, tone).or_else(|| fit_pairs_on(sensor, reference, colour, Some(EDGE_CONTRAST), tone)) +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) } fn fit_pairs_on( @@ -548,6 +715,106 @@ fn fit_pairs_on( Some(look) } +/// Weight of the photo's own colour matrix in a [`fit_partial`] look; the rest is the identity (the +/// neutral fallback's generic camera ≈ sRGB colour). On the CC0 archive's proxies, with the +/// matrix at full weight 2 of the 23 colourful photos without a look came out more than 2° further +/// from the camera's hues than the neutral fallback, at half weight none (median hue error 9.6° with +/// the generic colour, which the tone and chroma curves make visible, 5.1° at half weight). +const PARTIAL_MATRIX_WEIGHT: f64 = 0.5; + +/// Smallest rank correlation of scene and camera JPEG luminance over a [`fit_partial`] look's pairs: +/// the JPEG must show the same picture. A look that only matches the brightness distribution of an +/// unrelated picture can still beat the dark neutral fallback pixel for pixel. On the CC0 archive's +/// proxies the accepted full looks have 0.55 or more, lens-corrected compacts without one 0.53–0.77, +/// three old Coolpix files whose raw decodes wrongly 0.07–0.27. +const PARTIAL_MIN_RANK_CORRELATION: f64 = 0.5; + +/// Spearman rank correlation of the scene's and the JPEG's luminance over `pairs`. +fn rank_correlation(pairs: &[([f64; 3], [f64; 3])]) -> f64 { + let ranks = |values: Vec| { + let mut order: Vec = (0..values.len()).collect(); + order.sort_by(|a, b| values[*a].total_cmp(&values[*b])); + let mut ranks = vec![0.0; values.len()]; + for (rank, i) in order.into_iter().enumerate() { + ranks[i] = rank as f64; + } + ranks + }; + let scene = ranks(pairs.iter().map(|(x, _)| luma(*x)).collect()); + let jpeg = ranks(pairs.iter().map(|(_, y)| luma(*y)).collect()); + let mean = (pairs.len() as f64 - 1.0) / 2.0; + let (covariance, variance) = + scene.iter().zip(&jpeg).fold((0.0, 0.0), |(c, v), (a, b)| (c + (a - mean) * (b - mean), v + (a - mean) * (a - mean))); + if variance > 0.0 { covariance / variance } else { 0.0 } +} + +/// A partial look for a photo whose full look misses the acceptance gates (lens-corrected JPEGs of +/// compacts, dull or nearly colourless scenes): the tone curve by quantile matching, which needs +/// no pixel-exact registration and was the largest part of the neutral fallback's error (rendered +/// 9–22 L* too dark), the chroma curve (the fallback kept about a third of the camera's +/// colourfulness), and, when the scene has enough colour to learn one (the full look's condition), +/// the photo's own colour matrix at [`PARTIAL_MATRIX_WEIGHT`]. No hue/saturation table. Tried on +/// all pixels and then, like the full look, on the pixels away from edges; used only when it cuts +/// the held-out squared error to below [`MIN_IMPROVEMENT`] of the neutral fallback's, in linear +/// and in gamma-encoded display values, and when the JPEG shows the same picture +/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly +/// closer to the camera JPEG than what the photo would get otherwise. +fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option { + [None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit)) +} + +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 related = rank_correlation(&pairs); + if related < PARTIAL_MIN_RANK_CORRELATION { + if lightcraft_pipeline::profiling() { + eprintln!("[profile] camera look partial: luminance rank correlation {related:.3}, not the same picture"); + } + return None; + } + let spread = |y: &[f64; 3]| y.iter().copied().fold(f64::NEG_INFINITY, f64::max) - y.iter().copied().fold(f64::INFINITY, f64::min); + let colourful = pairs.iter().filter(|(_, y)| spread(y) > 0.05).count() >= pairs.len() / 20; + let matrix = match colourful.then(|| fit_matrix(&pairs)).flatten() { + Some(own) => Mat3(std::array::from_fn(|r| { + std::array::from_fn(|c| Mat3::IDENTITY.0[r][c] + PARTIAL_MATRIX_WEIGHT * (own.0[r][c] - Mat3::IDENTITY.0[r][c])) + })), + None => Mat3::IDENTITY, + }; + let tone_pairs: Vec<_> = + pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(matrix.apply(*x).map(|v| v.max(0.0))), luma(*y))).collect(); + let mut look = CameraLook { matrix, tone: fit_tone(tone_pairs)?, hue_sat: None }; + if let Some(tone) = fit_chroma(&bright, &look) { + look.tone = tone; + } + let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2); + let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0); + for (x, target) in pairs.iter().step_by(3) { + let (fallback, partial) = (displayed(*x, &neutral), displayed(matrix.apply(*x), &tone)); + for c in 0..3 { + before += (fallback[c] - target[c]).powi(2); + after += (partial[c] - target[c]).powi(2); + before_encoded += (encoded(fallback[c]) - encoded(target[c])).powi(2); + after_encoded += (encoded(partial[c]) - encoded(target[c])).powi(2); + samples += 1; + } + } + if lightcraft_pipeline::profiling() { + eprintln!( + "[profile] camera look partial (tone{}{}) holdout RMS {:.5} -> {:.5} ({samples} channels{})", + if look.tone.chroma().iter().any(|k| *k != 1.0) { ", chroma" } else { "" }, + if matrix != Mat3::IDENTITY { ", damped matrix" } else { "" }, + (before / samples.max(1) as f64).sqrt(), + (after / samples.max(1) as f64).sqrt(), + if edge_limit.is_some() { ", away from edges" } else { "" } + ); + } + let better = + after.is_finite() && after_encoded.is_finite() && after < before * MIN_IMPROVEMENT && after_encoded < before_encoded * MIN_IMPROVEMENT; + (samples > 0 && better).then_some(look) +} + /// Linear Rec.2020 D65 → linear ProPhoto RGB D50, the space DNG hue/saturation tables work in. fn to_prophoto() -> Mat3 { PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()) @@ -853,6 +1120,23 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option { mod tests { use super::*; + #[test] + fn camera_jpeg_matches_the_sensor_proxy_size_exactly() { + // An ILCE-7RM4 ARW (9504×6336 crop) embeds a 1616×1080 preview; for the camera-profile proxy + // it is decoded at 384×257 (`proxies`: twice the proxy edge) next to a 192×128 sensor proxy. + let sensor = fit(&Rgb32f::new(9504, 6336), PROFILE_PROXY, PROFILE_PROXY, Filter::Box); + assert_eq!((sensor.width, sensor.height), (192, 128)); + let jpeg = Rgb32f::new(384, 257); + // what fitting gave before: a pixel short, so every pair was rejected + assert_eq!(fit(&jpeg, sensor.width, sensor.height, Filter::Box).width, 191); + let r = reference_at(&jpeg, &sensor); + assert_eq!((r.width, r.height), (192, 128)); + // and at the single-photo proxy size, where fitting happened to land on the same size + let sensor = fit(&Rgb32f::new(9504, 6336), PROXY, PROXY, Filter::Box); + let r = reference_at(&Rgb32f::new(192, 129), &sensor); + assert_eq!((r.width, r.height), (sensor.width, sensor.height)); + } + #[test] fn linear_arw_gets_a_sensor_proxy_without_demosaicing() { use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect}; @@ -1165,21 +1449,42 @@ mod tests { } } - /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model. + /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit + /// is tried. #[test] fn a_files_own_matrices_come_first() { 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 mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", color.clone()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour_with(&mut raw, |_, _| panic!("no JPEG fit for a raw with its own matrices")); assert_eq!(raw.color, color); assert!(!t.matrix_is_fallback && look.is_none()); } - /// Step 2: a camera profile comes before the spectral matrices; when its fit can't run (here no camera JPEG) the - /// spectral matrices follow. The ILCE-7M4 has a bundled profile and a spectral row. + /// Steps 2 and 3 before step 4: an accepted JPEG fit (with or without a camera profile) is kept for a model that + /// has spectral matrices, and the spectral matrices are not written. Photos that had a fitted look before the + /// spectral table render exactly as they did. #[test] - fn a_camera_profile_is_tried_before_the_spectral_matrices() { + fn an_accepted_jpeg_fit_comes_before_the_spectral_matrices() { + for (make, model) in [("SONY", "ILCE-7M4"), ("Canon", "Canon EOS 600D")] { + assert!(lightcraft_raw::spectral::find(Some(make), model).is_some(), "{model}"); + let mut raw = raw_of(RawFormat::Cr2, make, model, Default::default()); + let fitted = dro_test_look(&std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)), &|x| x.sqrt()); + let (xy, t, look) = starting_colour_with(&mut raw, |raw, t| { + assert!(t.matrix_is_fallback && !lightcraft_raw::color::has_matrix(&raw.color), "the fit sees the raw without spectral matrices"); + Some(fitted.clone()) + }); + assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}"); + assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out"); + assert!(t.matrix_is_fallback, "{model}"); + assert_eq!(xy, lightcraft_raw::color::as_shot_white_xy(&raw)); + } + } + + /// Step 4: when the JPEG fit fails (here: no camera JPEG at all), a model with a spectral row takes its matrices, + /// whether or not it has a camera profile (the ILCE-7M4 has a bundled one). + #[test] + fn a_failed_jpeg_fit_falls_back_to_the_spectral_matrices() { 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()); @@ -1187,15 +1492,10 @@ mod tests { assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); - } - /// Step 3: a camera without a profile takes its spectral matrices, and no JPEG fit is tried: the look is `None` - /// and the transform is the DNG model's, at the as-shot white the matrices give the vendor multipliers. - #[test] - fn spectral_matrices_come_before_the_photos_own_fit() { let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap(); let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default()); - let (xy, t, look) = starting_colour(&mut raw, &[]); + let (xy, t, look) = starting_colour_with(&mut raw, |_, _| None); assert!(look.is_none()); assert_eq!(raw.color.illuminant, [17, 21]); assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some)); @@ -1206,10 +1506,9 @@ mod tests { assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); } - /// Steps 4 and 5: a camera outside the table keeps today's path, the photo's own JPEG fit and else the neutral - /// fallback (here: no camera JPEG, so the fallback). + /// Step 5: a camera outside the table whose fit fails keeps the neutral fallback. #[test] - fn a_camera_outside_the_table_keeps_the_fit_or_the_fallback() { + 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, &[]); assert!(look.is_none() && t.matrix_is_fallback); @@ -1220,6 +1519,37 @@ mod tests { assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); } + /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is + /// accepted keeps it and its colour data; one whose fit fails takes its spectral matrices when its model has + /// them; a DNG keeps its own matrices. White balance stays relative to the as-shot look for every raw without + /// matrices of its own. + #[test] + fn corpus_colour_precedence() { + 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"); + 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"); + continue; + }; + let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let own = lightcraft_raw::color::has_matrix(&raw.color); + let covered = raw.metadata.model.as_deref().and_then(|m| lightcraft_raw::spectral::find(raw.metadata.make.as_deref(), m)).is_some(); + let before = raw.color.clone(); + let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let spectral = !own && fitted.is_none() && covered; + eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); + assert_eq!(raw.color != before, spectral, "{name}"); + assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); + let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!(info.camera_tone.is_some(), fitted.is_some(), "{name}"); + assert_eq!(info.relative_wb, !own, "{name}"); + } + } + #[test] fn supported_raws_get_a_file_local_look() { // every decoded raw without a colour matrix of its own (issue #310: CR2 rendered flat with the fallback) @@ -1233,6 +1563,7 @@ mod tests { RawFormat::Cr2, RawFormat::Pef, RawFormat::Srw, + RawFormat::Orf, ]; assert!(local.into_iter().all(file_local_look)); // DNG carries its own colour model @@ -1290,7 +1621,8 @@ mod tests { }; assert_eq!(raw.format, RawFormat::Cr3); assert_eq!(raw.info().developed_size(), (6000, 4000)); - let (_, _, look) = starting_colour(&mut raw.clone(), &bytes); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform); 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(); @@ -1299,6 +1631,40 @@ 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() { + 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"); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} 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 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 raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR")); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform).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) { + assert!((got - want).abs() < 1e-12, "the look's matrix is not the profile's: {:?} vs {:?}", look.matrix.0, expected.0); + } + } + assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); + } + /// 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] @@ -1316,6 +1682,30 @@ mod tests { assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); } + /// The public ILCE-7CR samples (61 MP; skipped without the corpus): lossless compressed L and M and compressed + /// ARW2 all get a look fitted to their own JPEG, not the neutral fallback. + #[test] + fn corpus_a7cr_codings_get_a_camera_look() { + 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"); + for name in ["arw-sony-a7cr-lossless-l.arw", "arw-sony-a7cr-lossless-m.arw", "arw-sony-a7cr-compressed.arw"] { + 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"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); + assert_eq!((img.width, img.height), (400, 267), "{name}: framed in the default crop"); + assert!(info.camera_tone.is_some(), "{name}: no camera look fitted"); + assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0, "{name}"); + } + } + /// A public DC-FZ1000 II RW2 shot at 4:3 on its 3:2 sensor (skipped without the corpus): the default crop is 4:3 /// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it. #[test] @@ -1336,8 +1726,7 @@ mod tests { /// Issue #232 (skipped without the corpus): the public ILCE-7RM2 sample's camera JPEG is /// distortion-corrected, and on its glass façade the fit on all pixels misses the gate - /// (held-out RMS 0.113): it used to open grey with the neutral fallback. The ILCE-7RM2 now starts from its - /// spectral matrices; the fit away from edges is still what a camera outside that table gets. + /// (held-out RMS 0.113): it used to open grey with the neutral fallback. #[test] fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { let path = std::env::var_os("LIGHTCRAFT_CORPUS") @@ -1348,48 +1737,186 @@ mod tests { eprintln!("skip: {} absent", path.display()); return; }; - 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)); - assert!(fit_preview(&raw, &bytes, &transform, None, true).is_some(), "no camera look fitted"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert!(info.camera_tone.is_none(), "the spectral matrices come before the photo's own fit"); - assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0); + assert!(info.camera_tone.is_some(), "no camera look fitted"); } - /// The colour precedence on public raws (each skipped without its corpus file): the ILCE-7M4 has a bundled - /// profile, fitted for tone to its JPEG; the EOS 5D Mark III and D5100 have no profile and take their spectral - /// matrices; the EOS 7D is in neither and keeps its own fit; a DNG keeps its own matrices. White balance stays - /// relative to the as-shot look for every raw without matrices of its own. + /// 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 { + matrix: Mat3([[1.6, -0.4, -0.2], [-0.2, 1.4, -0.2], [0.0, -0.3, 1.3]]), + tone: CameraTone::new(xs.map(|x| [x, f(x)])).unwrap().with_chroma([1.3, 1.2, 1.1, 1.0, 1.0, 0.9, 0.6, 0.3]).unwrap(), + hue_sat: Some(HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[5.0, 1.1, 1.0]; 8], srgb_value: false }), + } + } + + /// `fixed` (from `fitted`) is `min(fitted, Sony's DRO-off curve)` at every knot but the + /// second-last, which may stay higher (at most the fitted curve) to keep the shoulder past the + /// last knot below the fitted one; nowhere brighter than `fitted`, colour unchanged. + fn assert_lowered(fitted: &CameraLook, fixed: &CameraLook, what: &str) { + let dro_off = sony_dro_off_tone().unwrap(); + assert_eq!((fixed.matrix, &fixed.hue_sat, fixed.tone.chroma()), (fitted.matrix, &fitted.hue_sat, fitted.tone.chroma()), "{what}"); + for (i, (k, own)) in fixed.tone.knots().iter().zip(fitted.tone.knots()).enumerate() { + let lowered = own[1].min(dro_off.apply(own[0])); + assert_eq!(k[0], own[0], "{what}: knot {i}"); + if i == 30 { + assert!(k[1] >= lowered && k[1] <= own[1], "{what}: knot {i} {} not in {lowered}..={}", k[1], own[1]); + } else { + assert!((k[1] - lowered).abs() < 1e-6, "{what}: knot {i} {} vs {lowered}", k[1]); + } + } + assert!(at_most(&fixed.tone, &fitted.tone), "{what}: brighter than the fitted curve somewhere"); + // the same check spelled out, from deep shadows to far past the last knot (the shoulder) + for i in 0..=2400 { + let x = 0.001 * 1.005f32.powi(i); + assert!(fixed.tone.apply(x) <= fitted.tone.apply(x) + 1e-5, "{what} at {x}: {} > {}", fixed.tone.apply(x), fitted.tone.apply(x)); + } + } + + /// Issue #244: Sony's Dynamic Range Optimizer lifts the camera JPEG's shadows, not the raw's. A + /// look fitted to such a JPEG keeps its colour (matrix, hue/saturation table, chroma curve) and + /// its curve is lowered to Sony's without DRO wherever that is darker, never raised. #[test] - fn corpus_colour_precedence() { + fn a_dro_look_keeps_its_colour_and_is_lowered_to_the_dro_off_tone() { + let dro_off = sony_dro_off_tone().expect("the built-in curve is valid"); + // the curve itself: black stays black, increasing, mid-grey where cameras put it (L* 50–75) + assert_eq!(dro_off.apply(0.0), 0.0); + let mut previous = 0.0; + for i in 1..200 { + let y = dro_off.apply(i as f32 * 0.005); + assert!(y > previous && y < 1.0, "{i}: {y}"); + previous = y; + } + assert!((0.18..0.48).contains(&dro_off.apply(0.18)), "{}", dro_off.apply(0.18)); + let xs: [f32; 32] = std::array::from_fn(|i| 0.006 * 1.13f32.powi(i as i32)); + // a JPEG with lifted shadows: the curve comes down to Sony's, colour and chroma curve stay + let lifted = dro_test_look(&xs, &|x| dro_off.apply(x).powf(0.8)); + let fixed = with_dro_off_tone(lifted.clone()); + assert_lowered(&lifted, &fixed, "lifted"); + assert!(fixed.tone.apply(0.02) < 0.85 * lifted.tone.apply(0.02)); + // a camera that maps raw values darker than Sony's curve keeps its own curve exactly + let darker = dro_test_look(&xs, &|x| 0.7 * dro_off.apply(x)); + assert_eq!(with_dro_off_tone(darker.clone()).tone, darker.tone); + // crossing curves: the lower of the two at the knots, still a valid increasing curve + let crossing = dro_test_look(&xs, &|x| (dro_off.apply(x) * (0.6 + x)).min(0.95)); + assert_lowered(&crossing, &with_dro_off_tone(crossing.clone()), "crossing"); + } + + /// Past its last knot a camera curve continues as a shoulder whose rate comes from its last two + /// knots. Lowering the second-last knot more than the last steepened that shoulder: the curve + /// fitted to the CC0 NEX-5T sample (raw.pixls.us, DRO Auto) went from 0.740 to 0.843 at scene + /// luminance 0.63, brighter than before. The lowered curve must stay at or below the fitted one + /// through the shoulder, on that curve and on many others. + #[test] + fn the_lowered_curve_stays_below_the_fitted_one_past_its_last_knot() { + let nex5t: [[f32; 2]; 32] = [ + [0.014948029, 0.017424058], + [0.018395446, 0.023981506], + [0.021539066, 0.030581286], + [0.024555191, 0.0374646], + [0.027602654, 0.044408206], + [0.030193308, 0.049894042], + [0.03261828, 0.055907387], + [0.034544908, 0.06156683], + [0.03669959, 0.06814718], + [0.039161213, 0.075263545], + [0.04173663, 0.0830011], + [0.044166446, 0.088166036], + [0.04682434, 0.095398724], + [0.049544845, 0.1027153], + [0.05235826, 0.11173598], + [0.05497513, 0.119017586], + [0.057503678, 0.12616614], + [0.059836045, 0.13521175], + [0.062677935, 0.14601946], + [0.06599234, 0.15573128], + [0.069809504, 0.16883339], + [0.0755404, 0.1854325], + [0.08250754, 0.20684941], + [0.089204684, 0.23466235], + [0.09650368, 0.25805998], + [0.102161564, 0.27849936], + [0.107812546, 0.29706743], + [0.11410169, 0.31379074], + [0.12000221, 0.33262977], + [0.12776275, 0.35407156], + [0.1387929, 0.38362882], + [0.19492775, 0.43935108], + ]; + let fitted = CameraLook { tone: CameraTone::new(nex5t).unwrap(), ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; + let fixed = with_dro_off_tone(fitted.clone()); + assert_lowered(&fitted, &fixed, "NEX-5T"); + assert!((fitted.tone.apply(0.63) - 0.7405).abs() < 1e-3 && fixed.tone.apply(0.63) <= fitted.tone.apply(0.63)); + assert_ne!(fixed.tone, fitted.tone, "the lifted mid-tones still come down"); + // curves that end at all kinds of scene luminances, slopes and heights relative to Sony's + let dro_off = sony_dro_off_tone().unwrap(); + let mut seed = 0x9e37_79b9_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 65536.0 + }; + for case in 0..300 { + let (start, span) = (0.002 + 0.03 * next(), 1.05 + 0.15 * next()); + let xs: [f32; 32] = std::array::from_fn(|i| start * span.powi(i as i32)); + let (lift, gain, top) = (0.5 + 0.6 * next(), 0.6 + 0.8 * next(), next()); + // brighter or darker than Sony's, with a last knot sometimes lowered less than the one before + let f = |x: f32| (gain * dro_off.apply(x).powf(lift) + top * 0.2 * (x / xs[31]).powi(8)).min(0.995); + let mut ys = xs.map(f); + for i in 1..32 { + ys[i] = ys[i].max(ys[i - 1]); + } + let Some(tone) = CameraTone::new(std::array::from_fn(|i| [xs[i], ys[i]])) else { continue }; + let fitted = CameraLook { tone, ..dro_test_look(&std::array::from_fn(|i| 0.01 + i as f32 * 0.01), &|x| x) }; + assert_lowered(&fitted, &with_dro_off_tone(fitted.clone()), &format!("case {case}")); + } + } + + /// Issue #244 (skipped without the corpus): the public ILCE-7RM2 sample (Standard creative + /// style) was shot with DRO Auto, so its curve comes down to Sony's without DRO in the shadows the + /// camera lifted; the ILCE-7M4 sample (DRO Auto, Vivid) has deeper shadows than that curve and + /// keeps them, only its brighter highlights come down. Both keep the colour fitted to their + /// JPEGs. The ILCE-7M3 sample (DRO off) keeps its own fit. + #[test] + fn corpus_arw_with_dro_is_lowered_to_the_dro_off_tone() { 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"); - // (file, spectral matrices used, camera look fitted) - for (name, spectral, look) in [ - ("arw-sony-a7m4-14bit.arw", false, true), - ("cr2-canon-5d3.cr2", true, false), - ("nef-nikon-d5100-lossless.nef", true, false), - ("cr2-canon-7d.cr2", false, true), - ("dng-canon-5d3-16bit.dng", false, false), - ] { - let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); - continue; + let samples = [ + ("arw-sony-a7rm2-12bit-uncompressed.arw", true, [0.01, 0.05, 0.1]), + ("arw-sony-a7m4-14bit.arw", true, [0.3, 0.4, 0.5]), + ("arw-sony-a7m3-compressed.arw", false, [0.0; 3]), + ]; + for (name, dro, lowered) in samples { + let path = dir.join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} absent", path.display()); + continue; + } + Err(e) => panic!("{}: {e}", path.display()), }; + assert_eq!(lightcraft_raw::embedded_preview_dynamic_range_optimized(&bytes), Some(dro), "{name}"); let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - let own = lightcraft_raw::color::has_matrix(&raw.color); - let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes); - assert_eq!(raw.color != before, spectral, "{name}"); - assert_eq!(fitted.is_some(), look, "{name}"); - assert_eq!(t.matrix_is_fallback, !own && !spectral, "{name}"); + let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let look = fit_preview(&raw, &bytes, &transform).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 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(); + if dro { + assert_lowered(&fitted, &look, name); + for x in lowered { + assert!(look.tone.apply(x) < 0.95 * fitted.tone.apply(x), "{name} at {x}: {} vs {}", look.tone.apply(x), fitted.tone.apply(x)); + } + } else { + assert_eq!((look.matrix, &look.hue_sat, look.tone), (fitted.matrix, &fitted.hue_sat, fitted.tone), "{name}"); + } let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); - assert_eq!(info.camera_tone.is_some(), look, "{name}"); - assert_eq!(info.relative_wb, !own, "{name}"); + assert_eq!(info.camera_tone, Some(look.tone), "{name}"); } } @@ -1448,9 +1975,7 @@ mod tests { .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); - // The GFX100S shares the GFX 100's sensor (rawtoaces-data's alias list), so it starts from those spectral - // matrices instead of a look fitted to its JPEG. - for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", false), ("raf-fuji-xt20-compressed.raf", true)] { + for (name, accepted) in [("raf-fuji-xt2-865.raf", true), ("raf-fuji-gfx100s-4503.raf", true), ("raf-fuji-xt20-compressed.raf", true)] { let Ok(bytes) = std::fs::read(dir.join(name)) else { continue }; let header = crate::files::probe_bytes(name, &bytes).unwrap(); assert_eq!(header.as_shot_wb, Some((6500.0, 0.0)), "{name}"); @@ -1535,6 +2060,110 @@ mod tests { (sensor, reference) } + /// A camera rendering with constant colourfulness (no highlight bleaching) of a colourful scene + /// under a bright neutral sky, whose sensor pixels are clipped: one channel held at the clip + /// level turns the white sky magenta in the raw while the camera JPEG keeps it white. Returns + /// the sensor proxy, the JPEG proxy and the clip mask. + fn clipped_sky_scene() -> (Rgb32f, Rgb32f, Vec) { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + let mut clipped = vec![false; w * h]; + let mut seed = 0x2545_f491_u32; + let mut next = move || { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + f32::from((seed >> 16) as u16) / 65536.0 + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + let o = 1.0 - (-2.5 * y).exp(); + p.map(|v| (v * o / y).clamp(0.0, 0.97)) + }; + // the sky: a quarter of the frame, white in the scene and in the JPEG + let neutral = known.inverse().unwrap().apply_f32([0.32; 3]); + for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() { + let scene = if i < w * h / 4 { + clipped[i] = true; + let sky = neutral.map(|v| v * (0.8 + 0.4 * next())); + *dst = camera(known.apply_f32(sky)); + // the green sample clips, red and blue (after white balance) go on rising + *src = [sky[0] * 1.35, sky[1] * 0.85, sky[2] * 1.4]; + continue; + } else { + // colourful foliage, leaves, bark and bright flowers from dark to light + let ev = 0.02 + next() * 0.4; + [ev * (0.6 + next() * 0.8), ev, ev * (0.5 + next() * 0.8)] + }; + *src = scene; + *dst = camera(known.apply_f32(scene)); + } + (sensor, reference, clipped) + } + + /// Mean colourfulness (distance from neutral of luminance-normalised RGB) of the unclipped + /// pixels rendered with `look`, over that of the camera JPEG, per display-luminance band. + fn colourfulness_ratio(look: &CameraLook, sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], band: std::ops::Range) -> f64 { + let chroma = |p: [f64; 3]| { + let y = luma(p); + p.iter().map(|v| (v / y - 1.0).powi(2)).sum::().sqrt() + }; + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let (mut rendered, mut camera) = (0.0, 0.0); + for ((x, y), c) in sensor.data.iter().zip(&reference.data).zip(clipped) { + let y = y.map(f64::from); + if *c || !band.contains(&luma(y)) { + continue; + } + rendered += chroma(displayed(look.matrix.apply(x.map(f64::from)), &tone)); + camera += chroma(y); + } + rendered / camera + } + + /// Clipped sensor pixels are left out of the look's training pairs: a clipped white sky no longer + /// dulls the colour matrix or makes the chroma curve remove colour at the sky's brightness. + #[test] + fn clipped_sky_does_not_dull_the_look() { + let (sensor, reference, clipped) = clipped_sky_scene(); + let old = fit_pairs(&sensor, &reference).expect("the earlier fit accepts the look"); + let new = fit_look(&sensor, &reference, &clipped, None).expect("look without the clipped pixels"); + let (mid, bright) = (0.1..0.35, 0.35..0.75); + let old_ratio = ( + colourfulness_ratio(&old, &sensor, &reference, &clipped, mid.clone()), + colourfulness_ratio(&old, &sensor, &reference, &clipped, bright.clone()), + ); + let new_ratio = + (colourfulness_ratio(&new, &sensor, &reference, &clipped, mid), colourfulness_ratio(&new, &sensor, &reference, &clipped, bright)); + // with the clipped sky in the pairs, the bright colours lose much of their colour + assert!(old_ratio.1 < 0.8, "earlier fit: colourfulness vs camera {old_ratio:?}, chroma {:?}", old.tone.chroma()); + // without it, the render keeps the camera's colourfulness at every brightness + for r in [new_ratio.0, new_ratio.1] { + assert!((0.9..1.1).contains(&r), "colourfulness vs camera {new_ratio:?}, chroma {:?}", new.tone.chroma()); + } + assert!(new.tone.chroma().iter().all(|k| *k > 0.85), "no colour cut: {:?}", new.tone.chroma()); + } + + /// When the accepted attempt misses the gates without the clipped pixels (here: too few pixels + /// left), the look fitted on all pixels is kept unchanged. + #[test] + fn clipped_fit_falls_back_to_all_pixels() { + let (sensor, reference, _) = clipped_sky_scene(); + let all = fit_pairs(&sensor, &reference).unwrap(); + // nearly everything clipped: too few pairs remain, the earlier look is used unchanged + let mut clipped = vec![true; sensor.data.len()]; + clipped[..100].iter_mut().for_each(|c| *c = false); + let fallback = fit_look(&sensor, &reference, &clipped, None).expect("the fallback keeps the look"); + assert_eq!((fallback.matrix.0, fallback.tone), (all.matrix.0, all.tone)); + // nothing clipped: the same look as before, exactly + let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap(); + 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!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); + } + /// Quantile matching is tried first; the conditional-median fit is the fallback and is used /// only where the quantile look misses the acceptance gates, so no photo loses its look. #[test] @@ -1556,4 +2185,120 @@ mod tests { let conditional = fit_pairs_ordered(&sensor, &reference, None, &[ToneFit::ConditionalMedian]).unwrap(); assert_eq!(swapped.tone, conditional.tone); } + + /// The camera look of `scene` (the known matrix of these tests, then a bright camera curve). + fn bright_camera(p: [f32; 3]) -> [f32; 3] { + let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]); + let q = known.apply_f32(p); + let y = luminance_2020(q); + if y <= 0.0 { [0.0; 3] } else { q.map(|v| v * (1.0 - (-4.0 * y).exp()) / y) } + } + + /// Mean squared error (all channels, display-linear) of `render` against `target` over `scene`. + fn mean_error(scene: &Rgb32f, target: impl Fn([f32; 3]) -> [f32; 3], render: impl Fn([f64; 3]) -> [f64; 3]) -> f64 { + let error: f64 = scene + .data + .iter() + .map(|x| { + let (p, t) = (render(x.map(f64::from)), target(*x)); + (0..3).map(|c| (p[c] - f64::from(t[c])).powi(2)).sum::() + }) + .sum(); + error / (3 * scene.data.len()) as f64 + } + + /// A colourful scene with fine detail everywhere (foliage) whose camera JPEG is lens-corrected: + /// barrel distortion moves the picture by up to about 4 proxy pixels toward the corners, so the + /// detail no longer pairs with itself and no global look passes the gates, on all pixels or + /// away from edges. The partial look still follows the camera's brightness, colourfulness and + /// hues: on the registered scene it is far closer to the camera than the neutral fallback, + /// which renders the scene dark and grey. + #[test] + fn partial_look_when_a_lens_corrected_jpeg_misses_the_gates() { + let (w, h) = (96usize, 64usize); + let scene = |u: f32, v: f32| -> [f32; 3] { + let base = 0.1 * (1.0 + 0.6 * (0.05 * u + 0.04 * v).sin()); + let detail = 1.0 + 0.8 * (1.7 * u).sin() * (1.3 * v).sin(); + let y = base * detail; + [y * (0.6 + 0.5 * (0.07 * u).sin().abs()), y, y * (0.5 + 0.6 * (0.09 * v + 0.03 * u).cos().abs())] + }; + let (cx, cy) = (w as f32 / 2.0, h as f32 / 2.0); + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + let (dx, dy) = (x as f32 - cx, y as f32 - cy); + let k = 1.0 + 0.08 * (dx * dx + dy * dy) / (cx * cx); + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = bright_camera(scene(cx + dx * k, cy + dy * k)); + } + } + assert!(fit_pairs(&sensor, &reference).is_none(), "the full look misses the gates"); + let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look instead of the neutral fallback"); + assert!(look.hue_sat.is_none()); + assert_ne!(look.matrix, Mat3::IDENTITY, "a colourful scene gets the damped matrix"); + // on the registered scene, against what the camera renders + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let partial = mean_error(&sensor, bright_camera, |x| displayed(look.matrix.apply(x), &tone)); + let fallback = mean_error(&sensor, bright_camera, |x| displayed(x, &neutral)); + assert!(partial < fallback * 0.25, "partial {partial:.5}, neutral fallback {fallback:.5}"); + assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8)); + } + + /// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is + /// learnt and the full look is never tried) whose camera renders it two stops brighter than the + /// neutral fallback: the partial look is the tone curve (and chroma curve) alone. + #[test] + fn partial_look_brightens_a_dull_scene_without_learning_colour() { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.01 + (i % 37) as f32 * 0.004; + let tint = if i % 50 == 0 { 1.3 } else { 1.0 + (i % 5) as f32 * 0.004 }; + *p = [ev * tint, ev, ev * (2.0 - tint)]; + } + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-12.0 * y).exp()) / y) + }; + let mut reference = sensor.clone(); + reference.map_in_place(camera); + assert!(fit_pairs(&sensor, &reference).is_none(), "too little colour for the full look"); + let look = fit_look(&sensor, &reference, &vec![false; w * h], None).expect("a partial look"); + assert_eq!(look.matrix, Mat3::IDENTITY, "no colour matrix from a colourless scene"); + let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let lightness = + |tone: &ToneMap| sensor.data.iter().map(|p| f64::from(tone.apply(luminance_2020(*p)))).sum::() / sensor.data.len() as f64; + let target = reference.data.iter().map(|p| f64::from(luminance_2020(*p))).sum::() / reference.data.len() as f64; + assert!((lightness(&tone) / target - 1.0).abs() < 0.05, "{} vs {target}", lightness(&tone)); + assert!(lightness(&neutral) < target * 0.6, "the neutral fallback is far darker"); + } + + /// When the neutral fallback already matches the camera JPEG, or the JPEG is unrelated or + /// monochrome, the partial look isn't clearly better and the photo keeps the neutral fallback. + #[test] + fn partial_look_only_when_clearly_better_than_the_neutral_fallback() { + let (w, h) = (96usize, 64usize); + let mut sensor = Rgb32f::new(w, h); + for (i, p) in sensor.data.iter_mut().enumerate() { + let ev = 0.02 + (i % 41) as f32 * 0.012; + *p = [ev * (0.7 + (i % 11) as f32 * 0.05), ev, ev * (0.7 + (i % 13) as f32 * 0.045)]; + } + let none = vec![false; w * h]; + // the camera renders exactly as the neutral fallback: nothing to gain + let neutral = ToneMap::new(0.0, 0.0, 0.0); + let mut reference = sensor.clone(); + reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32)); + assert!(fit_partial(&sensor, &reference).is_none(), "no clear gain over the fallback"); + // an unrelated picture + for (i, p) in reference.data.iter_mut().enumerate() { + *p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01]; + } + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "unrelated JPEG"); + // a black-and-white JPEG of the scene + reference.data.iter_mut().zip(&sensor.data).for_each(|(r, s)| *r = [0.8 * luminance_2020(*s).sqrt(); 3]); + assert!(fit_look(&sensor, &reference, &none, None).is_none(), "monochrome JPEG"); + } } diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 80192cf6e..6c35c2a45 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -25,6 +25,7 @@ const MAX_FILE: u64 = 4 << 20; /// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. pub const BUNDLED: &[(&str, &str)] = &[ + ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index e5074d9ed..6406db479 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -286,7 +286,7 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result: return Err("the library's own folders can't be moved here".into()); } rename_folder_on_disk(from, to)?; - let ops: Vec = s + let mut ops: Vec = s .catalog .photos() .filter_map(|p| match &p.source { @@ -301,6 +301,8 @@ pub(crate) fn move_folder(s: &mut Session, from: &str, to: &str) -> std::result: }) .collect(); let n = ops.len(); + // what the library knows about the folder (and those inside it) goes along, in the same step + ops.extend(s.catalog.folder_records_follow(from, to)); let name = Path::new(to).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); let renamed = src.parent() == dst.parent(); let label = if renamed { format!("Rename Folder to “{name}”") } else { format!("Move Folder “{name}”") }; diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index e946d1fa9..2b1b368b2 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -210,6 +210,25 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P ops } +/// Ops that give a photo the lens data its file carries now (`info`, a fresh probe), or none when +/// it already has them. A catalog from before the lens data was read at import has none, so such a +/// photo's correction stayed off. When the photo gains lens data and its develop settings are still +/// what import gave it, the lens correction is turned on as import would; an edited photo keeps +/// its settings. +pub(crate) fn lens_ops(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec { + if info.embedded_lens == ph.embedded_lens { + return Vec::new(); + } + let mut ops = vec![Op::SetEmbeddedLens { id, lens: info.embedded_lens.map(Box::new) }]; + // (a photo with a default-preset look keeps it: that look is its import state) + if ph.embedded_lens.is_none() && info.embedded_lens.is_some() && ph.import_look.is_none() && !ph.is_edited() && !ph.develop.optics.lens_profile { + let mut d = (*ph.develop).clone(); + d.optics.lens_profile = true; + ops.push(Op::SetDevelop { id, settings: std::sync::Arc::new(d), label: "Lens Corrections".into(), edited: ph.edited.clone() }); + } + ops +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result { @@ -229,21 +248,19 @@ fn reload(s: &mut Session, p: &Value) -> Result { // 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 Some(op) = content_op(id, ph, info.clone()) else { - if !meta_ops.is_empty() { - ops.extend(meta_ops); - reloaded.push(id); - } - continue; - }; - ops.push(op); - ops.extend(meta_ops); + let mut mine: Vec = content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(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)) { - ops.extend(content_op(c.id, c, info.clone())); - reloaded.push(c.id); + let theirs: Vec = content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).collect(); + if !theirs.is_empty() { + ops.extend(theirs); + reloaded.push(c.id); + } } - reloaded.push(id); } for id in &reloaded { s.media.forget(*id); @@ -316,7 +333,7 @@ pub fn edit_specs() -> Vec { "Reload from Disk", ["Photo"], None, - "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}", + "{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}", has_selection, reload ), @@ -359,4 +376,102 @@ mod tests { ph.captured = Some("2026-10-04T08:16:11".into()); assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + + 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() }), + vignette: None, + } + } + fn old_rw2() -> lightcraft_catalog::Photo { + // as an older build catalogued it: no lens data, correction off + lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(1), + Source::File { path: "/x.rw2".into() }, + "x.rw2", + "RW2", + 4592, + 3448, + "2026-10-06T00:00:00", + ) + } + + #[test] + fn reload_adds_lens_data_and_turns_the_correction_on_for_an_untouched_photo() { + let ph = old_rw2(); + let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() }; + let ops = lens_ops(ph.id, &ph, &info); + assert!(matches!(ops.first(), Some(Op::SetEmbeddedLens { lens: Some(_), .. })), "{ops:?}"); + let Some(Op::SetDevelop { settings, .. }) = ops.get(1) else { panic!("{ops:?}") }; + assert!(settings.optics.lens_profile); + // once applied, the photo is still "unedited" and has what import would have given it + let mut c = lightcraft_catalog::Catalog::default(); + c.apply(Op::AddPhoto { photo: Box::new(ph) }).unwrap(); + for op in ops { + c.apply(op).unwrap(); + } + let after = c.photo(lightcraft_catalog::PhotoId(1)).unwrap(); + assert_eq!(after.embedded_lens, Some(lens())); + assert!(!after.is_edited()); + assert_eq!(*after.develop, after.camera_defaults()); + assert!(lens_ops(after.id, after, &info).is_empty(), "nothing more to do the second time"); + } + + #[test] + fn reload_adds_lens_data_but_keeps_an_edited_photos_settings() { + let mut ph = old_rw2(); + let mut d = (*ph.develop).clone(); + d.light.exposure = 0.7; + ph.develop = std::sync::Arc::new(d); + assert!(ph.is_edited()); + let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() }; + let ops = lens_ops(ph.id, &ph, &info); + assert!(matches!(ops.as_slice(), [Op::SetEmbeddedLens { lens: Some(_), .. }]), "{ops:?}"); + } + + #[test] + fn reload_leaves_a_photo_whose_user_switched_the_correction_off_alone() { + let mut ph = old_rw2(); + ph.embedded_lens = Some(lens()); + let mut d = ph.camera_defaults(); + d.optics.lens_profile = false; + ph.develop = std::sync::Arc::new(d); + let info = crate::media::ProbeInfo { embedded_lens: Some(lens()), ..Default::default() }; + assert!(lens_ops(ph.id, &ph, &info).is_empty()); + } + + /// End to end on a public Panasonic raw (skipped without the corpus): a catalog from before the lens data was read + /// gets it on Reload, the render changes to the corrected one, and Undo takes it all back. + #[test] + fn corpus_reload_corrects_an_rw2_catalogued_without_lens_data() { + let root = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .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()); + return; + } + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [f.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let ph = s.catalog.photo(id).unwrap().clone(); + assert!(ph.embedded_lens.is_some() && ph.develop.optics.lens_profile, "import reads it"); + // back to how 0.4.0 catalogued it + let mut old = (*ph.develop).clone(); + old.optics.lens_profile = false; + s.catalog.apply(Op::SetEmbeddedLens { id, lens: None }).unwrap(); + s.catalog.apply(Op::SetDevelop { id, settings: std::sync::Arc::new(old), label: "x".into(), edited: None }).unwrap(); + s.media.forget(id); + let before = s.render_now(id, 160, 120).unwrap().image.data; + let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["reloaded"], json!([id.0])); + let now = s.catalog.photo(id).unwrap().clone(); + assert_eq!(now.embedded_lens, ph.embedded_lens); + assert!(now.develop.optics.lens_profile && !now.is_edited()); + assert_ne!(s.render_now(id, 160, 120).unwrap().image.data, before, "the corrected render differs"); + s.execute("edit.undo", &json!({})).unwrap(); + let undone = s.catalog.photo(id).unwrap(); + assert!(undone.embedded_lens.is_none() && !undone.develop.optics.lens_profile, "one undo step"); + } } diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index e0aaaf957..33570f47b 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -1,7 +1,7 @@ //! Develop commands. All operate on the active photo unless `ids` are given (sync). use lightcraft_catalog::{Op, PhotoId, Version}; -use lightcraft_develop::{DevelopSettings, Preset, Section, SettingsGroup, Treatment, Upright, WbMode, controls}; +use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls}; use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop}; use serde_json::{Value, json}; @@ -74,6 +74,49 @@ fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32) d.crop.geometry = crop_fit_angle(w, h, angle, a); } +/// Photo `id` is on an older rendering process than [`ProcessVersion::LATEST`]. +fn outdated(s: &Session, id: PhotoId) -> bool { + s.catalog.photo(id).is_some_and(|p| p.develop.process.is_outdated()) +} + +fn already_current() -> String { + format!("already on the current process (version {})", ProcessVersion::LATEST.0) +} + +/// Update to Current Process applies to the selection (the selected photos, else the active +/// photo): one of them is on an older rendering process. Calls that name their photos are +/// checked by the command instead (`explicit_targets`). +fn has_outdated_process(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let sel = &s.selection; + let targets = if sel.ids.is_empty() { sel.active.as_slice() } else { sel.ids.as_slice() }; + if targets.iter().any(|id| outdated(s, *id)) { Ok(()) } else { Err(already_current()) } +} + +/// The photos a call names (`ids`, or `id`), each of which must be in the library; `None` when it +/// names none (the selection applies). +fn named_photos(s: &Session, p: &Value, c: &str) -> Result>> { + if !super::names_photos(p) { + return Ok(None); + } + let given = match p.get("ids").filter(|v| !v.is_null()) { + Some(Value::Array(a)) => a.clone(), + Some(_) => return Err(bad(c, "`ids` must be a list of photo ids")), + None => p.get("id").cloned().into_iter().collect(), + }; + if given.is_empty() { + return Err(bad(c, "`ids` names no photos")); + } + let ids = given + .iter() + .map(|v| match v.as_u64().map(PhotoId) { + Some(id) if s.catalog.photo(id).is_some() => Ok(id), + _ => Err(bad(c, format!("no such photo {v}"))), + }) + .collect::>>()?; + Ok(Some(ids)) +} + /// The user preset named by `p.id` (built-ins can't be changed). fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Preset> { let pid = str_param(p, "id").ok_or_else(|| bad(c, "missing id"))?; @@ -203,12 +246,44 @@ pub fn specs() -> Vec { } } }; - s.develop_op(id, fresh, "Reset") + // a fresh start renders with the current process (a section or slider reset doesn't change it) + s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..fresh }, "Reset") }) .collect(); s.commit("Reset", Op::Batch { ops })?; ok() }), + CommandSpec { + explicit_targets: true, + ..cmd!( + "develop.updateProcess", + "Update to Current Process", + ["Photo"], + None, + "{ids?}: moves the target photos (`ids`, else the selection) that are on an older rendering process to the current one, as one undo step (their look can change; the sliders keep their values; see docs/process-versions.md). Enabled while a selected photo is on an older process; named photos are checked themselves (each must exist, one must be on an older process) → {changed, process}", + has_outdated_process, + |s, p| { + const C: &str = "develop.updateProcess"; + const LABEL: &str = "Update to Current Process"; + 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), + }; + let ops: Vec = targets + .into_iter() + .filter_map(|id| s.develop_of(id).map(|d| (id, d))) + .filter(|(_, d)| d.process.is_outdated()) + .filter_map(|(id, d)| s.develop_op(id, DevelopSettings { process: ProcessVersion::LATEST, ..(*d).clone() }, LABEL)) + .collect(); + let n = ops.len(); + if n > 0 { + s.commit(LABEL, Op::Batch { ops })?; + } + Ok(json!({"changed": n, "process": ProcessVersion::LATEST})) + } + ) + }, cmd!( "develop.resetSection", "Reset Section", @@ -572,6 +647,33 @@ pub fn specs() -> Vec { Ok(()) }) }), + // ---- HDR editing + cmd!( + "develop.hdr", + "HDR", + [], + None, + "{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)", + has_active, + |s, p| { + let id = active(s, "develop.hdr")?; + let cur = s.develop_of(id).unwrap_or_default().hdr; + let enabled = bool_or(p, "enabled", !cur.enabled); + let max_ev = match p.get("maxEv") { + None => None, + Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?), + }; + let label = if enabled { "HDR On" } else { "HDR Off" }; + edit(s, "develop.hdr", label, |d| { + d.hdr.enabled = enabled; + if let Some(m) = max_ev { + d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT); + } + Ok(()) + })?; + Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)})) + } + ), // ---- geometry: Upright cmd!( "geometry.upright", diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index a8b25023b..e08d0d179 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -80,6 +80,17 @@ fn delete(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ + cmd!(query "export.contactSheet", "Export Contact Sheet PDF", [], None, + "{path, ids?: photo ids (default selection), paper?: a4|letter, landscape?: bool, columns?: 1..8, rows?: 1..10, captions?: bool} → {path, pages, photos, bytes}; replaces an existing output, never an original", + always, |s, p| { + let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad("export.contactSheet", "missing path"))?; + s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?; + let doc = crate::contact_sheet::prepare(s, p).and_then(|work| work.run(&mut |_, _| true)).map_err(|e| bad("export.contactSheet", e))?; + s.check_write_target(path).map_err(|e| bad("export.contactSheet", e))?; + crate::export::write_file(path, &doc.bytes).map_err(|e| bad("export.contactSheet", e))?; + Ok(json!({"path": path, "pages": doc.pages, "photos": doc.photos, "bytes": doc.bytes.len()})) + } + ), cmd!( "export.presets", "Export Presets", diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 876cf485e..b2c7e90f0 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -1,10 +1,10 @@ //! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge; //! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords. -use lightcraft_catalog::keywords::{clean, is_under}; +use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, cmd, str_param}; +use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; use crate::{Result, Session}; fn strs(p: &Value, key: &str) -> Vec { @@ -15,20 +15,54 @@ fn strs(p: &Value, key: &str) -> Vec { } } -/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed -/// keyword and is cleared when its keyword is deleted. +/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The +/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted. fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option) -> Result { - let n = match &op { - lightcraft_catalog::Op::Batch { ops } => ops.len(), - _ => 1, + use lightcraft_catalog::Op; + let (photos, listed) = match &op { + Op::Batch { ops } => { + (ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count()) + } + Op::SetMeta { .. } => (1, 0), + _ => (0, 1), }; - if n > 0 { + if photos + listed > 0 { s.commit(label, op)?; } if let Some(k) = s.filter.keyword.clone() { s.filter.keyword = follow(&k); } - Ok(json!({"changed": n})) + Ok(json!({"changed": photos, "listed": listed})) +} + +/// A keyword's attributes from command params over `base`: those not given keep their value. +fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo { + KeywordInfo { + synonyms: if p.get("synonyms").is_some() { + strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + } else { + base.synonyms + }, + include_on_export: bool_or(p, "includeOnExport", base.include_on_export), + export_containing: bool_or(p, "exportContaining", base.export_containing), + export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms), + person: bool_or(p, "person", base.person), + new_keywords_inside: base.new_keywords_inside, + } +} + +/// The keyword a command names (`keyword`), as the library writes it. +fn named_keyword(s: &Session, p: &Value, command: &str) -> Result { + let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?; + s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k)))) +} + +/// A catalog refusal as a command error; a taken name says how to merge. +fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError { + match e { + lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")), + e => bad(command, e.to_string()), + } } /// A named set of up to nine keywords. @@ -38,6 +72,12 @@ pub struct KeywordSet { pub keywords: Vec, } +/// Two set names (or recent keywords) are the same whatever the case of any of their letters, as +/// keywords are. +fn same_set(a: &str, b: &str) -> bool { + lightcraft_catalog::keywords::same(a.trim(), b.trim()) +} + /// The set name meaning "the nine most recently added keywords". pub const RECENT: &str = "Recent Keywords"; @@ -48,16 +88,54 @@ pub fn note_recent(s: &mut Session, added: &[String]) { if k.is_empty() { continue; } - s.recent_keywords.retain(|x| !x.eq_ignore_ascii_case(&k)); + s.recent_keywords.retain(|x| !same_set(x, &k)); s.recent_keywords.insert(0, k); } s.recent_keywords.truncate(9); let _ = s.save_prefs(); } +/// The largest keyword list file read: a file is input (a list of 100,000 keywords is ~2 MB). +const MAX_LIST_BYTES: u64 = 16 << 20; + +/// The characters Capture One's keyword importer refuses in a list (`|` can't be in a name here). +const CAPTURE_ONE_REFUSES: [char; 4] = [';', ',', '<', '>']; + +/// A keyword list file's text: at most `MAX_LIST_BYTES`, UTF-8 (with or without a byte-order mark). +fn read_list(path: &str) -> Result { + use std::io::Read; + let file = std::fs::File::open(path).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?; + let mut bytes = Vec::new(); + file.take(MAX_LIST_BYTES + 1).read_to_end(&mut bytes).map_err(|e| bad("keyword.import", format!("{path}: {e}")))?; + if bytes.len() as u64 > MAX_LIST_BYTES { + return Err(bad("keyword.import", format!("{path}: larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20))); + } + // Notepad and some exporters save UTF-16: say how to get the file in, not just that it isn't + if bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[0xfe, 0xff]) { + return Err(bad("keyword.import", format!("{path}: UTF-16 text — save it as UTF-8 and import it again"))); + } + String::from_utf8(bytes).map_err(|_| bad("keyword.import", format!("{path}: not UTF-8 text"))) +} + +/// A set's slots as typed: each cleaned in its place (an empty one is an empty slot, so the others +/// keep their ⌥ keys), each keyword once whatever its case (a second one leaves its slot empty), +/// nine at most, no empty ones at the end. +pub fn slots(typed: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + for k in typed.iter().take(9) { + let k = clean(k); + let twice = !k.is_empty() && out.iter().any(|x| lightcraft_catalog::keywords::same(x, &k)); + out.push(if twice { String::new() } else { k }); + } + while out.last().is_some_and(String::is_empty) { + out.pop(); + } + out +} + /// The nine keywords ⌥1–⌥9 apply: the current set's, or the recent ones. pub fn current_keywords(s: &Session) -> Vec { - let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| x.name.eq_ignore_ascii_case(n))); + let set = s.keyword_set.as_deref().and_then(|n| s.keyword_sets.iter().find(|x| same_set(&x.name, n))); let mut v = set.map_or_else(|| s.recent_keywords.clone(), |x| x.keywords.clone()); v.truncate(9); v @@ -75,13 +153,13 @@ pub fn specs() -> Vec { cmd!(query "keyword.sets", "Keyword Sets", [], None, "{} → {sets: [{name, keywords}], current, keywords: the nine ⌥1–⌥9 apply}", always, |s, _| Ok(keyword_sets_json(s))), cmd!("keyword.useSet", "Use Keyword Set", [], None, "{name} (\"Recent Keywords\" = the recently added ones)", always, |s, p| { let name = str_param(p, "name").map(str::trim).unwrap_or(RECENT); - s.keyword_set = if name.eq_ignore_ascii_case(RECENT) || name.is_empty() { + s.keyword_set = if same_set(name, RECENT) || name.is_empty() { None } else { Some( s.keyword_sets .iter() - .find(|x| x.name.eq_ignore_ascii_case(name)) + .find(|x| same_set(&x.name, name)) .ok_or_else(|| bad("keyword.useSet", format!("no keyword set `{name}`")))? .name .clone(), @@ -95,24 +173,53 @@ pub fn specs() -> Vec { "Save Keyword Set", [], None, - "{name, keywords?: [up to 9] (default: the current nine)} — replaces a set of that name and makes it current", + "{name, keywords?: [up to 9, \"\" = an empty slot] (default: the renamed set's, else the current nine), replace?: the set it renames, new?: refuse a name another set has} — replaces a set of that name (or `replace`, in its place) and makes it current", always, |s, p| { let name = str_param(p, "name") .map(str::trim) - .filter(|n| !n.is_empty() && !n.eq_ignore_ascii_case(RECENT)) + .filter(|n| !n.is_empty() && !same_set(n, RECENT)) .ok_or_else(|| bad("keyword.saveSet", "missing or reserved `name`"))? .to_string(); - let mut keywords: Vec = if p.get("keywords").is_some() { - strs(p, "keywords").iter().map(|k| clean(k)).filter(|k| !k.is_empty()).collect() + let same_name = |x: &KeywordSet, n: &str| same_set(&x.name, n); + // the set it renames, if any (an empty `replace` is none) + let renames = match str_param(p, "replace").map(str::trim).filter(|o| !o.is_empty()) { + Some(old) => Some( + s.keyword_sets + .iter() + .position(|x| same_name(x, old)) + .ok_or_else(|| bad("keyword.saveSet", format!("no keyword set `{old}`")))?, + ), + None => None, + }; + // keywords not given: the renamed set's own, else the current nine + let keywords = if p.get("keywords").is_some() { + slots(&strs(p, "keywords")) } else { - current_keywords(s) + match renames.and_then(|at| s.keyword_sets.get(at)) { + Some(x) => x.keywords.clone(), + None => current_keywords(s), + } }; - keywords.truncate(9); let set = KeywordSet { name: name.clone(), keywords }; - match s.keyword_sets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { - Some(x) => *x = set, - None => s.keyword_sets.push(set), + match renames { + // rename: in its place, onto a name no other set has + Some(at) => { + if s.keyword_sets.iter().enumerate().any(|(i, x)| i != at && same_name(x, &name)) { + return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already"))); + } + if let Some(x) = s.keyword_sets.get_mut(at) { + *x = set; + } + } + // a new set (`new`) never takes another set's name + None if bool_or(p, "new", false) && s.keyword_sets.iter().any(|x| same_name(x, &name)) => { + return Err(bad("keyword.saveSet", format!("there is a keyword set “{name}” already"))); + } + None => match s.keyword_sets.iter_mut().find(|x| same_name(x, &name)) { + Some(x) => *x = set, + None => s.keyword_sets.push(set), + }, } s.keyword_set = Some(name); s.save_prefs()?; @@ -122,11 +229,11 @@ pub fn specs() -> Vec { cmd!("keyword.deleteSet", "Delete Keyword Set", [], None, "{name}", always, |s, p| { let name = str_param(p, "name").ok_or_else(|| bad("keyword.deleteSet", "missing `name`"))?; let before = s.keyword_sets.len(); - s.keyword_sets.retain(|x| !x.name.eq_ignore_ascii_case(name)); + s.keyword_sets.retain(|x| !same_set(&x.name, name)); if s.keyword_sets.len() == before { return Err(bad("keyword.deleteSet", format!("no keyword set `{name}`"))); } - if s.keyword_set.as_deref().is_some_and(|c| c.eq_ignore_ascii_case(name)) { + if s.keyword_set.as_deref().is_some_and(|c| same_set(c, name)) { s.keyword_set = None; } s.save_prefs()?; @@ -145,12 +252,17 @@ pub fn specs() -> Vec { .and_then(Value::as_u64) .filter(|i| (1..=9).contains(i)) .ok_or_else(|| bad("keyword.toggleFromSet", "`index` must be 1..9"))?; - let Some(k) = current_keywords(s).get(i as usize - 1).cloned() else { + // (an empty slot does nothing) + 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 all = !ids.is_empty() - && ids.iter().all(|id| s.catalog.photo(*id).is_some_and(|ph| ph.meta.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)))); + && ids.iter().all(|id| { + s.catalog + .photo(*id) + .is_some_and(|ph| ph.meta.keywords.iter().any(|x| lightcraft_catalog::keywords::same(&clean(x), &clean(&k)))) + }); let ids: Vec = ids.iter().map(|i| i.0).collect(); let key = if all { "removeKeywords" } else { "addKeywords" }; // applying from Recent Keywords mustn't reshuffle the numbers under the keys @@ -185,6 +297,184 @@ pub fn specs() -> Vec { Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n))) } ), + cmd!( + query "keyword.info", + "Keyword Info", + [], + None, + "{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}", + always, + |s, p| { + let path = named_keyword(s, p, "keyword.info")?; + let info = s.catalog.keyword_info(&path).cloned(); + let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count(); + let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.insert("path".into(), json!(path)); + o.insert("listed".into(), json!(info.is_some())); + o.insert("count".into(), json!(count)); + o.entry("synonyms").or_insert(json!([])); + } + Ok(v) + } + ), + cmd!( + "keyword.create", + "Create Keyword", + [], + None, + "{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}", + always, + |s, p| { + let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?; + let parent = match p.get("parent") { + Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))), + Some(_) => None, + None => s.catalog.default_keyword_parent(), + }; + let path = clean(&match parent { + Some(parent) => format!("{parent}|{name}"), + None => name, + }); + 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) + .map_err(|e| refused("keyword.create", e, ""))?; + s.commit("Create Keyword", op)?; + if !photos.is_empty() { + note_recent(s, std::slice::from_ref(&path)); + } + Ok(json!({"keyword": path})) + } + ), + cmd!( + "keyword.edit", + "Edit Keyword", + [], + None, + "{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value", + always, + |s, p| { + let from = named_keyword(s, p, "keyword.edit")?; + let leaf = from.rsplit('|').next().unwrap_or(&from).to_string(); + let name = str_param(p, "name").unwrap_or(&leaf).to_string(); + let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default()); + let op = + s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?; + let to = match from.rsplit_once('|') { + Some((parent, _)) => format!("{parent}|{}", name.trim()), + None => name.trim().to_string(), + }; + commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() })) + } + ), + cmd!( + "keyword.move", + "Move Keyword", + [], + None, + "{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`", + always, + |s, p| { + let from = named_keyword(s, p, "keyword.move")?; + let parent = match p.get("parent") { + Some(Value::String(k)) => Some(k.clone()), + Some(Value::Null) => None, + _ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")), + }; + // where it lands, as the catalog spells it: a parent that doesn't exist yet is made + let leaf = from.rsplit('|').next().unwrap_or(&from).to_string(); + let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) { + Some(parent) => format!("{parent}|{leaf}"), + None => leaf, + }; + let op = s + .catalog + .move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false)) + .map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?; + commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() })) + } + ), + cmd!( + "keyword.purgeUnused", + "Purge Unused Keywords", + [], + None, + "{} — takes off the keyword list the keywords no photo has → {purged}", + always, + |s, _| { + let op = s.catalog.purge_unused_keywords_ops(); + let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?; + Ok(json!({"purged": r["listed"]})) + } + ), + cmd!( + "keyword.setDefaultParent", + "Put New Keywords Inside", + [], + None, + "{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)", + always, + |s, p| { + let keyword = match p.get("keyword") { + Some(Value::Null) | None => None, + Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?), + }; + let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?; + commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?; + Ok(json!({"keyword": s.catalog.default_keyword_parent()})) + } + ), + cmd!( + "keyword.export", + "Export Keywords", + [], + None, + "{path?} — the keyword list as a file (Lightroom Classic's format, which Capture One, Photo Supreme and Bridge read) → {keywords, captureOneRefuses: keywords with ; , < > that Capture One's importer refuses, unwritable: keywords left out because the format can't hold them (a name in brackets or braces, a line break, deeper than 64 levels; a synonym as \"path {synonym}\"), text when there is no path}", + always, + |s, p| { + let file = s.catalog.keyword_list_text(); + // Capture One's importer refuses ; , < > in a keyword's name + let refuses: Vec<&str> = file + .written + .iter() + .filter_map(|p| p.rsplit(lightcraft_catalog::keywords::SEP).next()) + .filter(|n| n.contains(CAPTURE_ONE_REFUSES)) + .collect(); + let mut r = json!({"keywords": file.written.len(), "captureOneRefuses": refuses, "unwritable": file.unwritable}); + match str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()) { + Some(path) => { + std::fs::write(path, &file.text).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?; + r["path"] = json!(path); + } + None => r["text"] = json!(file.text), + } + Ok(r) + } + ), + cmd!( + "keyword.import", + "Import Keywords", + [], + None, + "{path | text} — a keyword list (Lightroom Classic's format, also Capture One's and Photo Supreme's Formatted Vocabulary File): adds the keywords the library doesn't have, gives those it has the list's synonyms; one undo step → {added, updated}", + always, + |s, p| { + let text = match (str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()), str_param(p, "text")) { + (Some(path), _) => read_list(path)?, + (None, Some(text)) if text.len() as u64 > MAX_LIST_BYTES => { + return Err(bad("keyword.import", format!("larger than {} MB, not a keyword list", MAX_LIST_BYTES >> 20))); + } + (None, Some(text)) => text.to_string(), + (None, None) => return Err(bad("keyword.import", "give `path` or `text`")), + }; + let entries = lightcraft_catalog::keywords::parse_keyword_list(&text).map_err(|e| bad("keyword.import", e.to_string()))?; + let (op, added, updated) = s.catalog.import_keywords_ops(&entries); + commit_keywords(s, "Import Keywords", op, |k| Some(k.to_string()))?; + Ok(json!({"added": added, "updated": updated})) + } + ), cmd!( "keyword.rename", "Rename Keyword", @@ -197,9 +487,7 @@ pub fn specs() -> Vec { let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string(); let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?; let (f, t) = (clean(&from), clean(&to)); - commit_keywords(s, "Rename Keyword", op, |k| { - Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() }) - }) + commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() })) } ), cmd!( @@ -229,8 +517,8 @@ pub fn specs() -> Vec { let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?; let (from, into) = (from.iter().map(|f| clean(f)).collect::>(), clean(&into)); commit_keywords(s, "Merge Keywords", op, |k| { - Some(match from.iter().find(|f| is_under(k, f)) { - Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]), + Some(match closest(k, &from) { + Some(f) => reparent(k, f, &into), None => k.to_string(), }) }) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 09d19f0dd..27e96f4fd 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -7,6 +7,20 @@ use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param}; use crate::{LibrarySource, Result, Selection, Session}; +fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { + let at = before.iter().position(|id| Some(*id) == s.selection.active); + let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); + s.selection.ids.retain(|id| remaining.contains(id)); + s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); + if s.selection.active.is_none() + && let Some(at) = at + { + // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. + let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); + s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); + } +} + /// The auto-import folder's visible files with their sizes (the file-system half of /// `library.autoImportScan`; the app lists on a worker thread and passes `listing`). pub fn list_auto_import_folder(folder: &str) -> std::result::Result, String> { @@ -146,6 +160,24 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O Ok(json!({"changed": n})) } +/// 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) { + 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 { + ops.push(Op::SetRating { id, rating }); + } + } + 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); @@ -330,6 +362,16 @@ pub fn specs() -> Vec { if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder { return Err(bad("library.filter", "a folder is already shown (library.source): show another source first")); } + // checked like a smart album's rules when this call sets them: an unknown field or a value + // that can't match is an error, not an empty grid. Rules that stopped checking since + // (their album was deleted) don't block other filter changes. + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } + let sets_rules = p.get("ruleSet").is_some_and(|r| !r.is_null()); + if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { + return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); + } s.filter = f; Ok(json!({"count": s.visible().len()})) } @@ -339,7 +381,7 @@ pub fn specs() -> Vec { "Library Folders", [], None, - "{} → [{name, path, count, own, volume, selectable, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", + "{} → [{name, path, count, own, volume, selectable, label?, children}] the disks and the folders the library's photos were imported from, with photo counts (subfolders included in `count`); show one with library.source {kind: libraryFolder, path} (rows with selectable false only open)", always, |s, _| Ok(serde_json::to_value(s.catalog.folder_tree()).unwrap_or_default()) ), @@ -387,6 +429,33 @@ pub fn specs() -> Vec { Ok(json!({"removed": ids.len()})) } ), + cmd!( + "folder.label", + "Set Folder Color Label", + [], + None, + "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}", + always, + |s, p| { + const C: &str = "folder.label"; + let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? { + "none" => None, + x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?), + }; + // taking a label off always works, also once the folder's photos are gone + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() { + return Err(bad(C, format!("{path}: no photo in the library was imported from it"))); + } + if label.is_none() && s.catalog.folder_record(path).is_none() { + return Ok(json!({"path": path, "label": label})); + } + let op = s.catalog.folder_label_op(path, label); + s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?; + Ok(json!({"path": path, "label": label})) + } + ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { s.filter = Default::default(); Ok(json!({"count": s.visible().len()})) @@ -564,6 +633,12 @@ pub fn specs() -> Vec { advance_if(s, p); Ok(v) }), + cmd!("photo.decreaseRating", "Decrease Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| { + change_rating(s, p, -1, "Decrease Rating") + }), + cmd!("photo.increaseRating", "Increase Rating", [], None, "{ids?, advance?: bool}", has_selection, |s, p| { + change_rating(s, p, 1, "Increase Rating") + }), cmd!("photo.flag", "Set Flag", ["Photo", "Set Flag"], None, "{flag: pick|reject|none, ids?, advance?: bool}", has_selection, |s, p| { let f = str_param(p, "flag").and_then(Flag::parse).ok_or_else(|| bad("photo.flag", "flag must be pick|reject|none"))?; let v = for_targets(s, p, "Set Flag", |id| Some(Op::SetFlag { id, flag: f }))?; @@ -662,18 +737,8 @@ 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 at = before.iter().position(|id| Some(*id) == s.selection.active); let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; - let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect(); - s.selection.ids.retain(|id| remaining.contains(id)); - s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied()); - if s.selection.active.is_none() - && let Some(at) = at - { - // Keep culling near the deleted photo: next visible survivor, or the previous one at the end. - let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id)); - s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default(); - } + select_after_deletion(s, &before); Ok(v) }), cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { @@ -699,10 +764,11 @@ 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 op = s.catalog.delete_photos_permanently_ops(&t); s.commit("Delete Permanently", op)?; - s.selection = Selection::default(); + select_after_deletion(s, &before); Ok(json!({"deleted": t.len()})) }), // ---- metadata @@ -766,16 +832,26 @@ pub fn specs() -> Vec { *field = v.to_string(); } } - if let Some(k) = strs("keywords") { - m.keywords = k; - } - for k in strs("addKeywords").unwrap_or_default() { - if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) { + // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case + use lightcraft_catalog::keywords::{clean, same}; + let add = |m: &mut lightcraft_catalog::Meta, k: &str| { + let k = clean(k); + if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) { m.keywords.push(k); } + }; + if let Some(k) = strs("keywords") { + m.keywords.clear(); + for k in k { + add(&mut m, &k); + } + } + for k in strs("addKeywords").unwrap_or_default() { + add(&mut m, &k); } for k in strs("removeKeywords").unwrap_or_default() { - m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k)); + let k = clean(&k); + m.keywords.retain(|x| !same(&clean(x), &k)); } ops.push(Op::SetMeta { id, meta: Box::new(m) }); } @@ -819,7 +895,7 @@ pub fn specs() -> Vec { return Err(bad("album.createSmart", "empty name")); } let rules = match p.get("rules") { - Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart")?, + Some(r) => merge_rules(&lightcraft_catalog::Filter::default(), r, "album.createSmart", &s.catalog, None)?, None => view_rules(s), }; let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); @@ -829,8 +905,8 @@ pub fn specs() -> Vec { Ok(json!({"id": id.0, "count": s.catalog.album_count(id)})) } ), - cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind, ops: [{op, label}], choices?}] for ruleSet rules", always, |_, _| { - use lightcraft_catalog::rules::{FIELDS, Kind, ops_for}; + cmd!(query "album.ruleFields", "Smart Album Rule Fields", [], None, "{} → [{field, label, kind (text|keywords|number|date|choice|bool|album: an album id), group (field-menu submenu, null at the top level), ops: [{op, label}], choices? (ids), choiceLabels? ({id: label})}] for ruleSet rules, in menu order", always, |_, _| { + use lightcraft_catalog::rules::{FIELDS, Kind, field_group, ops_for}; Ok(json!(FIELDS .iter() .map(|(id, label, kind)| { @@ -838,13 +914,15 @@ pub fn specs() -> Vec { Kind::Text => "text", Kind::Keywords => "keywords", Kind::Number => "number", + Kind::Album => "album", Kind::Date => "date", Kind::Choice(_) => "choice", Kind::Bool => "bool", }; - let mut v = json!({"field": id, "label": label, "kind": k, "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); + let mut v = json!({"field": id, "label": label, "kind": k, "group": field_group(id), "ops": ops_for(*kind).iter().map(|(o, l)| json!({"op": o, "label": l})).collect::>()}); if let Kind::Choice(c) = kind { - v["choices"] = json!(c); + v["choices"] = json!(c.iter().map(|c| c.0).collect::>()); + v["choiceLabels"] = c.iter().map(|(id, label)| (id.to_string(), json!(label))).collect::>().into(); } v }) @@ -855,18 +933,34 @@ pub fn specs() -> Vec { "Edit Smart Album", [], None, - "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view)}", + "{id, rules?: partial Filter merged onto the current rules (null clears a field), replace?: bool, fromView?: bool (use the current view), name?: also rename it (blank keeps the name) — one undo step, both or neither}", always, |s, p| { let id = album_param(p, "id", "album.setRules")?; let cur = s.catalog.album(id).and_then(|a| a.smart.as_deref().cloned()).ok_or_else(|| bad("album.setRules", "not a smart album"))?; let rules = if bool_or(p, "fromView", false) { - view_rules(s) + // the view is saved as it is, but never as a loop (showing A, which tests this + // album, and saving that view here would make it test itself) + let view = view_rules(s); + let loops: Vec = view + .rule_set + .as_ref() + .map(|r| r.check_for(&s.catalog, Some(id))) + .unwrap_or_default() + .into_iter() + .chain(s.catalog.album_filter_problem(&view, Some(id))) + .filter(|p| p.issue == lightcraft_catalog::rules::Issue::AlbumLoop) + .map(|p| p.to_string()) + .collect(); + if !loops.is_empty() { + return Err(bad("album.setRules", loops.join("; "))); + } + view } else { let replace = bool_or(p, "replace", false); let folder = cur.library_folder.clone(); let base = if replace { Default::default() } else { cur }; - let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules")?; + let mut r = merge_rules(&base, p.get("rules").unwrap_or(&Value::Null), "album.setRules", &s.catalog, Some(id))?; // the rules dialog has no folder field: replacing its rules keeps the folder // unless the call says (`libraryFolder: null`) to drop it if replace && p.get("rules").and_then(|r| r.get("libraryFolder")).is_none() { @@ -874,7 +968,13 @@ pub fn specs() -> Vec { } r }; - s.commit("Edit Smart Album", Op::SetAlbumRules { id, rules: Box::new(rules) })?; + let set = Op::SetAlbumRules { id, rules: Box::new(rules) }; + // a new name goes with the rules: one step, applied (and undone) together + let rename = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty() && s.catalog.album(id).is_some_and(|a| a.name != *n)); + match rename { + Some(name) => s.commit("Edit Smart Album", Op::Batch { ops: vec![Op::RenameAlbum { id, name: name.to_string() }, set] })?, + None => s.commit("Edit Smart Album", set)?, + }; Ok(json!({"count": s.catalog.album_count(id)})) } ), @@ -884,14 +984,45 @@ pub fn specs() -> Vec { s.commit("Rename Album", Op::RenameAlbum { id, name })?; ok() }), - cmd!("album.delete", "Delete Album", [], None, "{id}", always, |s, p| { - let id = album_param(p, "id", "album.delete")?; - s.commit("Delete Album", Op::RemoveAlbum { id })?; - if s.source == LibrarySource::Album(id) { - s.source = LibrarySource::All; + cmd!( + "album.delete", + "Delete Album", + [], + None, + "{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step", + always, + |s, p| { + let id = album_param(p, "id", "album.delete")?; + let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder; + let mut children = std::collections::BTreeMap::>::new(); + for album in s.catalog.albums() { + if let Some(parent) = album.parent { + children.entry(parent).or_default().push(album.id); + } + } + // Iterative traversal: even a malformed cycle is rejected before any change. + // Reverse parent-first order removes children first; the batch inverse restores + // parents first, including memberships, smart rules, covers and manual ordering. + let mut pending = vec![id]; + let mut order = Vec::new(); + let mut removed = std::collections::BTreeSet::new(); + while let Some(next) = pending.pop() { + if !removed.insert(next) { + return Err(bad("album.delete", "cycle in album folders")); + } + order.push(next); + if let Some(nested) = children.remove(&next) { + pending.extend(nested); + } + } + let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect(); + s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?; + if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) { + s.source = LibrarySource::All; + } + ok() } - ok() - }), + ), cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| { let id = album_param(p, "id", "album.move")?; let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); @@ -1283,12 +1414,39 @@ pub fn specs() -> Vec { } /// `base` with a partial Filter (JSON) merged on top. -fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { +/// `patch` merged onto `base`, its rules brought up to date (`RuleSet::upgrade`), unchecked (see +/// [`merge_rules`]). +fn merge_filter(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Result { let mut v = serde_json::to_value(base).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, patch); - let f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; - if let Some(problem) = f.rule_set.as_ref().and_then(|r| r.problems().into_iter().next()) { - return Err(bad(c, problem)); + let mut f: lightcraft_catalog::Filter = serde_json::from_value(v).map_err(|e| bad(c, e.to_string()))?; + if let Some(rules) = f.rule_set.as_mut() { + rules.upgrade(); + } + Ok(f) +} + +/// `patch` merged onto `base`, refused when the rule set it sets has problems (`RuleSet::check_for`; +/// `owner` is the smart album the rules are for, so testing an album that leads back to it is a +/// problem too). +fn merge_rules( + base: &lightcraft_catalog::Filter, + patch: &Value, + c: &str, + cat: &lightcraft_catalog::Catalog, + owner: Option, +) -> Result { + let f = merge_filter(base, patch, c)?; + // only rules this change sets are checked: one that stopped checking since (its album was + // deleted) doesn't block editing the album's other settings + let sets_rules = patch.get("ruleSet").is_some_and(|r| !r.is_null()); + let mut problems = f.rule_set.as_ref().filter(|_| sets_rules).map(|r| r.check_for(cat, owner)).unwrap_or_default(); + // and a loop through the album field the change sets + if patch.get("album").is_some_and(|a| !a.is_null()) { + problems.extend(cat.album_filter_problem(&f, owner)); + } + if !problems.is_empty() { + return Err(bad(c, problems.iter().map(ToString::to_string).collect::>().join("; "))); } Ok(f) } @@ -1296,7 +1454,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res /// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup /// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of /// one disk but a way to reach several. -fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { +pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { ids.iter() .filter_map(|id| s.catalog.photo(*id)) .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) @@ -1324,7 +1482,9 @@ fn view_rules(s: &Session) -> lightcraft_catalog::Filter { } } } - merge_rules(&base, &Value::Object(patch), "").unwrap_or(base) + // the view as it is: a rule that no longer checks (its album was deleted) just matches + // nothing there, and mustn't cost the filter bar's settings + merge_filter(&base, &Value::Object(patch), "").unwrap_or(base) } None => { let mut f = s.source.to_filter(&s.filter, &s.catalog); @@ -1392,3 +1552,7 @@ mod gps_tests { assert_eq!(p("hello"), None); } } + +#[cfg(test)] +#[path = "library/tests_relative_ratings.rs"] +mod relative_rating_tests; diff --git a/crates/engine/src/cmd/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs new file mode 100644 index 000000000..1a1b8b23c --- /dev/null +++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs @@ -0,0 +1,102 @@ +//! Relative ratings dispatch through the real engine, including history and AutoWrite. +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +fn session() -> Result { + let mut s = Session::new(); + for (id, rating) in [(1, 0), (2, 2), (3, 5), (4, 1)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-relative-ratings/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-09", + ); + photo.rating = rating; + 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 ratings(s: &Session) -> Result> { + (1..=4) + .map(|id| s.catalog.photo(PhotoId(id)).map(|p| p.rating).ok_or_else(|| lightcraft_catalog::CatalogError::NoPhoto(PhotoId(id)).into())) + .collect() +} + +#[test] +fn relative_ratings_clamp_each_photo_and_form_one_history_step() { + let mut s = session().unwrap(); + let undo = s.undo.len(); + assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 2); + assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.active(), Some(PhotoId(2))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ratings(&s).unwrap(), [0, 2, 5, 1]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]); + assert_eq!(s.execute("photo.decreaseRating", &json!({})).unwrap()["changed"], 3); + assert_eq!(ratings(&s).unwrap(), [0, 2, 4, 1]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(ratings(&s).unwrap(), [1, 3, 5, 1]); +} + +#[test] +fn bounds_active_fallback_explicit_targets_and_errors_preserve_history() { + let mut s = session().unwrap(); + assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [1]})).unwrap()["changed"], 0); + assert_eq!(s.execute("photo.increaseRating", &json!({"ids": [3]})).unwrap()["changed"], 0); + assert!(s.undo.is_empty()); + s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) }; + assert_eq!(s.execute("photo.increaseRating", &json!({})).unwrap()["changed"], 1); + assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 1]); + assert_eq!(s.execute("photo.decreaseRating", &json!({"ids": [4]})).unwrap()["changed"], 1); + assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]); + let undo = s.undo.len(); + assert!(s.execute("photo.increaseRating", &json!({"ids": [1, 999]})).is_err()); + assert_eq!(ratings(&s).unwrap(), [0, 3, 5, 0]); + assert_eq!(s.undo.len(), undo); + s.execute("photo.increaseRating", &json!({"advance": true})).unwrap(); + assert_eq!(s.active(), Some(PhotoId(3))); + s.selection = Selection::default(); + assert!(s.execute("photo.decreaseRating", &json!({})).is_err()); +} + +#[test] +fn relative_ratings_and_history_use_auto_write() { + let mut s = session().unwrap(); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("lc-relative-ratings-{}-{stamp}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // Metadata-only fixtures: no import, image decoding or model/service dependency. + for id in 1..=4 { + s.catalog + .apply(Op::SetFile { + id: PhotoId(id), + file_name: format!("{id}.jpg"), + source: Source::File { path: dir.join(format!("{id}.jpg")).to_string_lossy().into_owned() }, + }) + .unwrap(); + } + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + s.execute("photo.increaseRating", &json!({})).unwrap(); + assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("1")); + assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("3")); + assert!(!dir.join("3.xmp").exists(), "unchanged upper bound must not write"); + assert!(!dir.join("4.xmp").exists(), "unselected photo must not write"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(std::fs::read_to_string(dir.join("1.xmp")).unwrap().contains("0")); + assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("2")); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(std::fs::read_to_string(dir.join("2.xmp")).unwrap().contains("3")); + s.execute("photo.decreaseRating", &json!({})).unwrap(); + assert!(std::fs::read_to_string(dir.join("3.xmp")).unwrap().contains("4")); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index 41b43cab9..d06271525 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -189,7 +189,13 @@ pub fn specs() -> Vec { } // photos without a capture time start from their import time let base = |s: &crate::Session, id| s.catalog.photo(id).map(|p| p.date().to_string()).unwrap_or_default(); - let mut delta = (f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0).round() as i64; + // seconds; a shift beyond 10,000 years is a mistake (and would overflow), not a date + const MAX_SHIFT: f64 = 10_000.0 * 365.25 * 86_400.0; + let shift = f64_or(p, "shift", 0.0) + f64_or(p, "hours", 0.0) * 3600.0; + if shift.is_nan() || shift.abs() > MAX_SHIFT { + return Err(bad(c, "the shift is more than 10,000 years")); + } + let mut delta = shift.round() as i64; let mut each: Option = None; if let Some(t) = str_param(p, "time") { let t = normalize_iso(t).ok_or_else(|| bad(c, format!("`{t}` is not a date (YYYY-MM-DDTHH:MM:SS)")))?; @@ -197,23 +203,38 @@ pub fn specs() -> Vec { each = Some(t); } else { let anchor = s.active().filter(|a| targets.contains(a)).unwrap_or(targets[0]); - let from = iso_seconds(&base(s, anchor)).ok_or_else(|| bad(c, "the photo's date doesn't parse"))?; + let anchor_date = base(s, anchor); + // An impossible calendar date cannot anchor changes to the other photos. + let from = normalize_iso(anchor_date.trim().get(..10).unwrap_or("")) + .and_then(|_| iso_seconds(&anchor_date)) + .ok_or_else(|| bad(c, "the photo's date doesn't parse"))?; delta += iso_seconds(&t).unwrap_or(from) - from; } } let mut ops = Vec::new(); let mut out = Vec::new(); + let mut out_of_range = 0usize; for id in &targets { - let new = match &each { - Some(t) => shift_iso(t, delta), - None => shift_iso(&base(s, *id), delta), + let from = match &each { + Some(t) => t.clone(), + None => base(s, *id), + }; + let Some(new) = shift_iso(&from, delta) else { + // a date that reads but would leave years 0000–9999 is refused below; one + // that doesn't read is skipped, as before + if iso_seconds(&from).is_some() { + out_of_range += 1; + } + continue; }; - let Some(new) = new else { continue }; out.push(json!(new)); if s.catalog.photo(*id).and_then(|p| p.captured.as_deref()) != Some(new.as_str()) { ops.push(Op::SetCaptured { id: *id, captured: Some(new) }); } } + if out_of_range > 0 { + return Err(bad(c, format!("the shift would take {out_of_range} photo(s) outside the years 0000–9999; nothing was changed"))); + } let n = ops.len(); if n > 0 { s.commit("Edit Capture Time", Op::Batch { ops })?; diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index dd1fa77cc..d72996523 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -255,7 +255,7 @@ pub fn specs() -> Vec { } }; let m = &mut masks[i]; - if let Some(MaskComponent { name: None, shape: MaskShape::Brush { strokes }, .. }) = + if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) = m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. })) { strokes.push(stroke); diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 3d5fb49a3..6f194c652 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id: !cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f)) } -fn relink_op(id: PhotoId, path: &str) -> Op { +pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op { let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ef838fe02..ebc82862b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -31,6 +31,7 @@ mod prefs; mod preset_files; pub mod previews; mod query; +mod sync; mod xmp; use serde::Serialize; @@ -54,6 +55,10 @@ pub struct CommandSpec { pub run: Run, /// Record in the journal (false for queries). pub journal: bool, + /// `enabled` judges the selection (menus, shortcuts); a call that names its photos (`ids` / + /// `id`, see [`names_photos`]) skips it, and the command validates those photos itself. + /// Off unless a command opts in (`CommandSpec { explicit_targets: true, ..cmd!(…) }`). + pub explicit_targets: bool, } #[derive(Clone, Debug, Serialize)] @@ -107,10 +112,10 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { macro_rules! cmd { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, explicit_targets: false } }; (query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false } + $crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, explicit_targets: false } }; } pub(crate) use cmd; @@ -142,6 +147,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); + v.extend(sync::specs()); v.extend(metadata::specs()); v.extend(filters::specs()); v.extend(denoise::specs()); @@ -158,6 +164,11 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> { // ---------- param helpers +/// The call names its target photos (`ids` or `id`, not null) instead of using the selection. +pub(crate) fn names_photos(p: &Value) -> bool { + ["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())) +} + pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 824039e46..fcf56f7b6 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -43,7 +43,9 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog) -> Va if let Some(rules) = &a.smart { v["smart"] = json!(true); v["rules"] = serde_json::to_value(rules).unwrap_or_default(); - v["rulesText"] = json!(rules.describe()); + v["rulesText"] = json!(rules.describe_with(cat)); + // rules that no longer check (an album they test was deleted…), as `rule 1: …` + v["problems"] = json!(cat.smart_album_problems(a.id).iter().map(ToString::to_string).collect::>()); } v } @@ -149,9 +151,18 @@ pub fn specs() -> Vec { } Ok(v) }), - cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| { + cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?} → the photo's develop settings; `process` is the rendering process they are interpreted by (see develop.updateProcess)", always, |s, p| { let id = photo_arg(s, p, "develop.get")?; - Ok(s.develop_of(id).map(|d| d.to_json()).unwrap_or(Value::Null)) + Ok(s.develop_of(id) + .map(|d| { + let mut v = d.to_json(); + // saved settings leave V1 out (`ProcessVersion::is_legacy`); here it is always stated + if let Some(o) = v.as_object_mut() { + o.insert("process".into(), json!(d.process)); + } + v + }) + .unwrap_or(Value::Null)) }), cmd!(query "develop.controls", "List Develop Controls", [], None, "{section?} — every slider with range, default and current value", always, |s, p| { let d = s.active().and_then(|id| s.develop_of(id)).unwrap_or_default(); diff --git a/crates/engine/src/cmd/sync.rs b/crates/engine/src/cmd/sync.rs new file mode 100644 index 000000000..81693e5ae --- /dev/null +++ b/crates/engine/src/cmd/sync.rs @@ -0,0 +1,61 @@ +//! Synchronize Folder commands (see [`crate::sync`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; +use crate::sync::{SyncChoice, scan, synchronize}; +use crate::{Result, Session}; + +fn path(p: &Value, c: &str) -> Result { + str_param(p, "path").filter(|d| !d.trim().is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `path`")) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "folder.scanChanges", + "Scan Folder for Changes", + [], + None, + "{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}", + always, + |s, p| { + const C: &str = "folder.scanChanges"; + let path = path(p, C)?; + let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?; + let v = serde_json::to_value(&changes).unwrap_or_default(); + s.folder_changes = Some(changes); + Ok(v) + } + ), + cmd!( + "folder.synchronize", + "Synchronize Folder", + [], + None, + "{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}", + always, + sync + ), + ] +} + +fn sync(s: &mut Session, p: &Value) -> Result { + const C: &str = "folder.synchronize"; + let path = path(p, C)?; + let d = SyncChoice::default(); + let choice = SyncChoice { + import_new: bool_or(p, "importNew", d.import_new), + relink_moved: bool_or(p, "relinkMoved", d.relink_moved), + remove_missing: bool_or(p, "removeMissing", d.remove_missing), + read_metadata: bool_or(p, "readMetadata", d.read_metadata), + }; + let changes = match s.take_folder_changes(&path) { + Some(c) => c, + // the person agreed to what a scan showed: never act on something else + None if bool_or(p, "scanned", false) => return Err(bad(C, format!("{path} changed since it was scanned: scan again"))), + None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?, + }; + let r = synchronize(s, changes, choice)?; + Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed})) +} diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs new file mode 100644 index 000000000..663d6f826 --- /dev/null +++ b/crates/engine/src/contact_sheet.rs @@ -0,0 +1,378 @@ +//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF. +use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image}; +use crate::{Session, media::RenderJob}; +use lightcraft_pipeline::{OutputDepth, OutputSpace}; +use lightcraft_raster::Rgba8; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Options { + pub paper: String, + pub landscape: bool, + pub columns: usize, + pub rows: usize, + pub captions: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { paper: "a4".into(), landscape: false, columns: 3, rows: 4, captions: true } + } +} + +impl Options { + pub fn parse(p: &Value) -> Result { + let mut settings = p.as_object().cloned().ok_or("params must be an object")?; + settings.remove("path"); + settings.remove("ids"); + settings.remove("id"); + let o: Self = serde_json::from_value(Value::Object(settings)).map_err(|e| e.to_string())?; + o.layout()?; + Ok(o) + } + + fn layout(&self) -> Result { + if !(1..=8).contains(&self.columns) || !(1..=10).contains(&self.rows) { + return Err("columns must be 1–8 and rows 1–10".into()); + } + let (mut w, mut h) = match self.paper.as_str() { + "a4" => (595.28, 841.89), + "letter" => (612.0, 792.0), + _ => return Err("paper must be a4 or letter".into()), + }; + if self.landscape { + std::mem::swap(&mut w, &mut h); + } + // Fixed 150 dpi, bounded paper sizes and grid dimensions keep allocations predictable. + let width = (w * 150.0 / 72.0_f64).round() as usize; + let height = (h * 150.0 / 72.0_f64).round() as usize; + Ok(Layout { points: (w, h), width, height, cell_w: (width - 120) / self.columns, cell_h: (height - 120) / self.rows }) + } +} + +struct Layout { + points: (f64, f64), + width: usize, + height: usize, + cell_w: usize, + cell_h: usize, +} + +pub struct Prepared { + options: Options, + items: Vec<(String, RenderJob)>, +} + +pub struct Document { + pub bytes: Vec, + pub pages: usize, + pub photos: usize, +} + +pub fn prepare(session: &mut Session, p: &Value) -> Result { + let options = Options::parse(p)?; + if let Some(ids) = p.get("ids") { + let a = ids.as_array().ok_or("ids must be an array of photo ids")?; + if a.iter().any(|v| v.as_u64().is_none()) { + return Err("ids must contain only photo ids".into()); + } + } + 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); + if ids.is_empty() || ids.len() > 1000 { + return Err("select between 1 and 1000 photos".into()); + } + let l = options.layout()?; + if ids.len().div_ceil(options.columns * options.rows) > 100 { + return Err("contact sheets are limited to 100 pages; increase the grid size or select fewer photos".into()); + } + let mut items = Vec::with_capacity(ids.len()); + for id in ids { + let name = session.catalog.photo(id).ok_or("no such photo")?.file_name.clone(); + let job = session.export_job(id, l.cell_w - 16, l.cell_h - 48, OutputSpace::Srgb, OutputDepth::U8)?; + items.push((name, job)); + } + Ok(Prepared { options, items }) +} + +impl Prepared { + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Cancellation produces no partial document; callers write only after this succeeds. + pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result { + let l = self.options.layout()?; + 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 page = Rgba8::filled(l.width, l.height, [255; 4]); + for (i, (name, job)) in self.items.into_iter().enumerate() { + if !progress(i, &name) { + return Err("Contact sheet cancelled".into()); + } + let image = job.run().rendered?.image; + let slot = i % capacity; + let x = 60 + slot % self.options.columns * l.cell_w; + let y = 60 + slot / self.options.columns * l.cell_h; + // The renderer fits within the requested rectangle and applies crop/orientation. + if image.width > l.cell_w - 16 || image.height > l.cell_h - 48 { + return Err("render exceeded contact sheet cell".into()); + } + blit(&mut page, &image, x + (l.cell_w - image.width) / 2, y + (l.cell_h - 40 - image.height) / 2)?; + if self.options.captions { + let mut caption = Rgba8::filled(l.cell_w - 16, 28, [255; 4]); + draw_watermark( + &mut caption, + &Watermark { + text: caption_text(&name, l.cell_w - 16), + size: 0.5, + opacity: 1.0, + anchor: Anchor::Center, + inset: 0.0, + color: [32; 3], + shadow: false, + ..Default::default() + }, + ); + blit(&mut page, &caption, x + 8, y + l.cell_h - 36)?; + } + 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 { + return Err("contact sheet exceeds 256 MiB; select fewer photos".into()); + } + pdf.page(i / capacity, &jpg, l.width, l.height); + page.data.fill([255; 4]); + } + } + if !progress(photos, "PDF") { + return Err("Contact sheet cancelled".into()); + } + Ok(Document { bytes: pdf.finish(), pages, photos }) + } +} + +fn caption_text(name: &str, width: usize) -> String { + use unicode_segmentation::UnicodeSegmentation; + let clean: String = name.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let cells: Vec<_> = clean.graphemes(true).collect(); + // A conservative one-em budget at 14 px also accommodates the optional CJK faces. + let limit = (width / 14).max(3); + if cells.len() <= limit { + return clean; + } + let left = limit / 2; + let right = limit - left - 1; + format!("{}…{}", cells.iter().take(left).copied().collect::(), cells.iter().skip(cells.len() - right).copied().collect::()) +} + +fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> { + if x.checked_add(src.width).is_none_or(|w| w > dst.width) || y.checked_add(src.height).is_none_or(|h| h > dst.height) { + return Err("contact sheet image outside page".into()); + } + for row in 0..src.height { + let start = (y + row) * dst.width + x; + let a = dst.data.get_mut(start..start + src.width).ok_or("invalid page buffer")?; + let b = src.data.get(row * src.width..(row + 1) * src.width).ok_or("invalid render buffer")?; + for (dst, src) in a.iter_mut().zip(b) { + let alpha = u32::from(src[3]); + for channel in 0..3 { + dst[channel] = ((u32::from(src[channel]) * alpha + u32::from(dst[channel]) * (255 - alpha) + 127) / 255) as u8; + } + dst[3] = 255; + } + } + 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::*; + use serde_json::json; + + #[test] + fn validates_settings_and_selection() { + let mut s = Session::with_demo(); + for p in [ + json!({"columns": 0}), + json!({"rows": 11}), + json!({"paper": "poster"}), + json!({"landscape": "yes"}), + json!({"typo": 2}), + json!({"ids": ["bad"]}), + json!({"ids": []}), + ] { + assert!(prepare(&mut s, &p).is_err(), "{p}"); + } + let a = Options::default().layout().unwrap(); + let b = Options { landscape: true, ..Default::default() }.layout().unwrap(); + assert_eq!((a.width, a.height), (b.height, b.width)); + let id = s.catalog.photos().next().unwrap().id.0; + assert!(prepare(&mut s, &json!({"ids": [id, u64::MAX]})).is_err()); + assert!(prepare(&mut s, &json!({"ids": vec![id; 1001]})).is_err()); + assert!(prepare(&mut s, &json!({"ids": vec![id; 101], "columns": 1, "rows": 1})).is_err()); + } + + #[test] + fn transparent_pixels_composite_over_white() { + let mut dst = Rgba8::filled(3, 1, [255; 4]); + let src = Rgba8 { width: 3, height: 1, data: vec![[0, 0, 0, 0], [255, 0, 0, 128], [10, 20, 30, 255]] }; + blit(&mut dst, &src, 0, 0).unwrap(); + assert_eq!(dst.data, vec![[255; 4], [255, 127, 127, 255], [10, 20, 30, 255]]); + assert!(blit(&mut dst, &src, usize::MAX, 0).is_err()); + } + + #[test] + fn unicode_captions_are_rasterized() { + assert_eq!(caption_text("very-long-éééé-filename.jpg", 140), "very-….jpg"); + assert_eq!(caption_text("café\n.jpg", 300), "café .jpg"); + let mut img = Rgba8::filled(360, 28, [255; 4]); + draw_watermark( + &mut img, + &Watermark { + text: "Été – Straße café.jpg".into(), + size: 0.5, + opacity: 1.0, + anchor: Anchor::Center, + inset: 0.0, + color: [32; 3], + shadow: false, + ..Default::default() + }, + ); + assert!(img.data.iter().filter(|p| p[0] < 128).count() > 100); + let mut ascii = Rgba8::filled(360, 28, [255; 4]); + draw_watermark( + &mut ascii, + &Watermark { + text: "Ete - Strasse cafe.jpg".into(), + size: 0.5, + opacity: 1.0, + anchor: Anchor::Center, + inset: 0.0, + color: [32; 3], + shadow: false, + ..Default::default() + }, + ); + assert_ne!(img, ascii); + } + + #[test] + fn pagination_xref_and_cancellation() { + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + let p = json!({"ids": ids, "columns": 1, "rows": 2, "captions": false}); + let mut seen = Vec::new(); + let doc = prepare(&mut s, &p) + .unwrap() + .run(&mut |i, _| { + seen.push(i); + true + }) + .unwrap(); + assert_eq!((doc.pages, doc.photos), (2, 3)); + assert_eq!(seen, vec![0, 1, 2, 3]); + let text = String::from_utf8_lossy(&doc.bytes); + assert!(text.contains("/Count 2 /Kids [3 0 R 6 0 R]")); + let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::().unwrap(); + assert!(doc.bytes[xref..].starts_with(b"xref\n0 9\n")); + let xref_text = std::str::from_utf8(&doc.bytes[xref..]).unwrap(); + for (index, line) in xref_text.lines().skip(3).take(8).enumerate() { + let offset = line.split_whitespace().next().unwrap().parse::().unwrap(); + assert!(doc.bytes[offset..].starts_with(format!("{} 0 obj\n", index + 1).as_bytes())); + } + assert!(prepare(&mut s, &p).unwrap().run(&mut |_, _| false).is_err()); + } + + fn first_page(bytes: &[u8]) -> Rgba8 { + let start = bytes.windows(2).position(|v| v == [0xff, 0xd8]).unwrap(); + let end = bytes[start..].windows(2).position(|v| v == [0xff, 0xd9]).unwrap() + start + 2; + lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8() + } + + #[test] + fn sheet_uses_current_edits_and_captions() { + let mut s = Session::with_demo(); + let p = json!({"columns": 1, "rows": 2, "captions": false}); + let plain = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap(); + let bright = first_page(&prepare(&mut s, &p).unwrap().run(&mut |_, _| true).unwrap().bytes); + let sum = |img: &Rgba8| img.data.iter().map(|p| u64::from(p[0]) + u64::from(p[1]) + u64::from(p[2])).sum::(); + assert!(sum(&bright) > sum(&plain), "sheet ignored develop settings"); + let captions = first_page(&prepare(&mut s, &json!({"columns": 1, "rows": 2})).unwrap().run(&mut |_, _| true).unwrap().bytes); + assert!(sum(&captions) < sum(&bright), "captions absent from PDF pixels"); + assert_eq!((plain.width, plain.height), (1240, 1754)); + } +} diff --git a/crates/engine/src/crs.rs b/crates/engine/src/crs.rs index 0b0c83b94..86c26c69d 100644 --- a/crates/engine/src/crs.rs +++ b/crates/engine/src/crs.rs @@ -18,6 +18,43 @@ use serde_json::{Map, Value, json}; /// Properties keyed `prefix:name` (as produced by [`lightcraft_meta::parse_xmp`]). pub type Props = BTreeMap>; +/// What the mapped settings will develop. It decides how a `crs:` white balance is read: a raw +/// with a measured illuminant takes `Temperature` as Kelvin; everything developed relative to its +/// as-shot look (rendered files, and raws whose readers have no illuminant, +/// [`lightcraft_catalog::relative_wb_format`]) needs a shift from the other app's as-shot white +/// instead, because on that scale 6500 K / 0 means *as shot* (issue #510). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + /// A preset: raw or rendered unknown. Absolute Kelvin first, else the relative scale. + Any, + /// A rendered photo (or a raw shown from its embedded preview): `Incremental*` fields on our + /// relative scale. + Rendered, + /// A raw developed with an absolute white balance (DNG and other files with a measured + /// illuminant): Kelvin as written. + RawAbsolute, + /// A raw developed relative to its as-shot look ([`lightcraft_catalog::Photo::relative_wb`]). + RawRelative, +} + +impl Target { + /// The target for a file of this media kind and format (its extension or Lightroom's + /// `fileFormat` name); `preview_only` for a raw that can't be decoded yet. + pub fn for_file(kind: lightcraft_catalog::MediaKind, format: &str, preview_only: bool) -> Target { + if kind != lightcraft_catalog::MediaKind::Raw || preview_only { + Target::Rendered + } else if lightcraft_catalog::relative_wb_format(format) { + Target::RawRelative + } else { + Target::RawAbsolute + } + } + /// The target for a photo in the catalog. + pub fn for_photo(p: &lightcraft_catalog::Photo) -> Target { + Target::for_file(p.kind, &p.format, p.preview_only.is_some()) + } +} + /// Band names as they appear in `crs:` field names, in our mixer order. const CRS_BANDS: [&str; 8] = ["Red", "Orange", "Yellow", "Green", "Aqua", "Blue", "Purple", "Magenta"]; @@ -69,6 +106,8 @@ pub fn has_adjustments(props: &Props) -> bool { thread_local! { /// Keys read by [`to_partial`] while [`to_partial_report`] runs. static READ: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// Adjustments [`to_partial`] read but deliberately left out, for the report. + static SKIPPED: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; } fn note(k: &str) { @@ -79,6 +118,14 @@ fn note(k: &str) { }); } +fn skipped(what: &str) { + SKIPPED.with(|r| { + if let Some(s) = r.borrow_mut().as_mut() { + s.push(what.to_string()); + } + }); +} + fn first<'a>(props: &'a Props, k: &str) -> Option<&'a str> { note(k); props.get(k).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty()) @@ -138,10 +185,8 @@ fn curve(props: &Props, k: &str) -> Option { /// Map the `crs:` fields of an XMP packet to a partial develop-settings JSON object. /// -/// `raw`: the target is a raw file (absolute Kelvin white balance) — `Some(false)` prefers the -/// relative `Incremental*` white balance used for rendered files, `None` (presets) takes whichever -/// is present, absolute first. -pub fn to_partial(props: &Props, raw: Option) -> Value { +/// `target` decides how the white balance is read (see [`Target`]). +pub fn to_partial(props: &Props, target: Target) -> Value { let mut out = Value::Object(Map::new()); let o = &mut out; let n = |o: &mut Value, crs: &str, path: &str| { @@ -200,10 +245,24 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { let rel = num(props, "crs:IncrementalTemperature") .or(num(props, "crs:IncrementalTint").map(|_| 0.0)) .map(|t| (rel_to_kelvin(t), num(props, "crs:IncrementalTint"))); - let wb = match raw { - Some(true) => abs, - Some(false) => rel.or(abs), - None => abs.or(rel), + // A Kelvin value for something we develop relative to its as-shot look: the other app's + // as-shot white (`AsShotTemperature` / `AsShotTint`, written next to the edit) is the + // reference, so the edit is the same mired shift from ours (issue #510). + let shifted = || { + let (t, tint) = abs?; + let as_shot = num(props, "crs:AsShotTemperature").filter(|k| *k > 0.0)?; + if t <= 0.0 { + return None; + } + let r = (1e6 / as_shot - 1e6 / t) / 0.8; + let tint = tint.map(|v| v - num(props, "crs:AsShotTint").unwrap_or(0.0)); + Some((rel_to_kelvin(r), tint)) + }; + let wb = match target { + Target::RawAbsolute => abs, + Target::Any => abs.or(rel), + Target::Rendered => rel.or_else(shifted).or(abs), + Target::RawRelative => rel.or_else(shifted), }; match (mode, wb) { (Some(m), _) if m != "custom" => put(o, "wb.mode", json!(m)), @@ -214,6 +273,13 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { put(o, "wb.tint", json!(tint)); } } + // a custom Kelvin with nothing to shift it from: read on the relative scale it would be a + // large colour shift (ΔE 22 against Lightroom's export on the photos of issue #510), so + // the photo stays As Shot (ΔE 12 there) and the report says so + (_, None) if target == Target::RawRelative && abs.is_some() => { + put(o, "wb.mode", json!("asShot")); + skipped("Temperature, Tint (custom white balance without AsShotTemperature: kept As Shot)"); + } (Some(m), None) => put(o, "wb.mode", json!(m)), (None, None) => {} } @@ -244,13 +310,19 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { put(o, "curve.master", c); } } - // Lightroom Classic reads the red / green / blue curves only together with the master 2012 - // curve: a channel curve without `ToneCurvePV2012` leaves the photo unchanged + // Lightroom Classic reads the red / green / blue curves only when the packet holds the master + // 2012 curve and all three channel curves (as its own packets always do): a packet missing any + // of them imports without channel curves (master + red, master + red + green, or red / green / + // blue without the master all leave the photo's channels unchanged there) if let Some(c) = curve(props, "crs:ToneCurvePV2012") { put(o, "curve.master", c); - for (crs, ch) in [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] { - if let Some(c) = curve(props, &format!("crs:{crs}")) { - put(o, &format!("curve.{ch}"), c); + let channels = [("ToneCurvePV2012Red", "red"), ("ToneCurvePV2012Green", "green"), ("ToneCurvePV2012Blue", "blue")] + .map(|(crs, ch)| (curve(props, &format!("crs:{crs}")), ch)); + if channels.iter().all(|(c, _)| c.is_some()) { + for (c, ch) in channels { + if let Some(c) = c { + put(o, &format!("curve.{ch}"), c); + } } } } @@ -375,11 +447,12 @@ pub fn to_partial(props: &Props, raw: Option) -> Value { /// With `values` (the packet's structured properties), local corrections become masks too /// ([`crate::crs_masks`]; radial masks fitted to `aspect` = width / height); components that /// can't be carried over are reported as `Mask: `. -pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, raw: Option, aspect: f64) -> (Value, Vec) { +pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values>, target: Target, aspect: f64) -> (Value, Vec) { READ.with(|r| *r.borrow_mut() = Some(Default::default())); - let mut out = to_partial(props, raw); + SKIPPED.with(|r| *r.borrow_mut() = Some(Vec::new())); + let mut out = to_partial(props, target); let mut read = READ.with(|r| r.borrow_mut().take()).unwrap_or_default(); - let mut mask_skips = Vec::new(); + let mut mask_skips = SKIPPED.with(|r| r.borrow_mut().take()).unwrap_or_default(); if let Some(values) = values { let (masks, skipped) = crate::crs_masks::masks(values, aspect); if !masks.is_empty() { @@ -387,7 +460,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values } if values.keys().any(|k| crate::crs_masks::CONTAINERS.contains(&k.as_str())) { read.extend(crate::crs_masks::CONTAINERS.iter().map(|c| c.to_string())); - mask_skips = skipped.into_iter().map(|k| format!("Mask: {k}")).collect(); + mask_skips.extend(skipped.into_iter().map(|k| format!("Mask: {k}"))); } } // fields that only switch a panel on/off or name things: not adjustments by themselves; an @@ -421,7 +494,7 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values pub fn preset_from_xmp(xmp: &str, fallback_name: &str) -> Option { let d = lightcraft_meta::parse_xmp(xmp).ok()?; let props = &d.properties; - let settings = to_partial(props, None); + let settings = to_partial(props, Target::Any); if settings.as_object().is_none_or(Map::is_empty) { return None; } @@ -474,7 +547,7 @@ mod tests { fn maps_common_fields() { let p = props(SIDECAR); assert!(has_adjustments(&p)); - let partial = to_partial(&p, Some(true)); + let partial = to_partial(&p, Target::RawAbsolute); let s = apply_partial(&DevelopSettings::for_raw(5000.0, 0.0), &partial, 1.0); assert_eq!(s.wb.mode, WbMode::Custom); assert_eq!((s.wb.temp, s.wb.tint), (5150.0, 12.0)); @@ -516,7 +589,7 @@ mod tests { fn only_present_fields_are_in_the_partial() { let x = r#""#; - let partial = to_partial(&props(x), None); + let partial = to_partial(&props(x), Target::Any); assert_eq!(partial, json!({"color": {"vibrance": 30.0}, "grain": {"amount": 10.0}})); } @@ -531,12 +604,12 @@ mod tests { 4800 "#; let p = props(x); - let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(false)), 1.0); + let rendered = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::Rendered), 1.0); assert_eq!(rendered.treatment, lightcraft_develop::Treatment::Bw); assert_eq!(rendered.bw_mix.blue, -35.0); assert!((rendered.wb.temp - rel_to_kelvin(25.0)).abs() < 1e-6 && rendered.wb.temp > 6500.0); assert_eq!(rendered.wb.tint, -6.0); - let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Some(true)), 1.0); + let raw = apply_partial(&DevelopSettings::default(), &to_partial(&p, Target::RawAbsolute), 1.0); assert_eq!(raw.wb.temp, 4800.0); } @@ -545,13 +618,72 @@ mod tests { let x = r#""#; let p = props(x); - assert_eq!(to_partial(&p, Some(true)), json!({"wb": {"mode": "daylight"}})); + assert_eq!(to_partial(&p, Target::RawAbsolute), json!({"wb": {"mode": "daylight"}})); assert!(!has_adjustments(&p)); let bookkeeping = r#""#; assert!(!has_adjustments(&props(bookkeeping))); } + #[test] + fn custom_kelvin_on_relative_wb_raws_shifts_from_the_as_shot_white() { + // Issue #510: a Sony ARW edited in Lightroom to Custom 3578 K / −5 next to an as-shot white + // of 3650 K / −2 (written for this test from the numbers in the issue). + let x = r#""#; + let p = props(x); + let rel = to_partial(&p, Target::RawRelative); + // 3650 → 3578 K is +5.5 mired: the same shift from 6500 K lands near 6275 K on our scale + let t = rel["wb"]["temp"].as_f64().unwrap(); + assert!((t - 6275.0).abs() < 5.0, "{t}"); + assert_eq!(rel["wb"]["tint"], -3.0); + assert_eq!(rel["wb"]["mode"], "custom"); + // a rendered file without `Incremental*` fields shifts the same way; a raw with a measured + // illuminant (and a preset) keeps the Kelvin value + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], rel["wb"]["temp"]); + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Any)["wb"]["temp"], 3578.0); + // the as-shot fields are a reference, not an adjustment: nothing to report + assert!(to_partial_report(&p, None, Target::RawRelative, 1.5).1.is_empty()); + // no tint next to the as-shot tint: the custom tint is taken as the shift itself + let no_tint = props(&x.replace(r#" crs:AsShotTint="-2""#, "")); + assert_eq!(to_partial(&no_tint, Target::RawRelative)["wb"]["tint"], -5.0); + } + + #[test] + fn custom_kelvin_without_an_as_shot_reference_stays_as_shot_and_is_reported() { + // The same edit from a Lightroom catalog, which keeps no as-shot white for Custom photos: + // 3578 K read on the relative scale would be a large blue shift (issue #510) + let x = r#""#; + let p = props(x); + let (partial, unmapped) = to_partial_report(&p, None, Target::RawRelative, 1.5); + assert_eq!(partial["wb"], json!({"mode": "asShot"})); + assert_eq!(partial["light"]["exposure"], 0.3); + assert_eq!(unmapped.len(), 1, "{unmapped:?}"); + assert!(unmapped[0].starts_with("Temperature, Tint"), "{unmapped:?}"); + // a raw with a measured illuminant and a rendered file read it as before, and report nothing + assert_eq!(to_partial(&p, Target::RawAbsolute)["wb"]["temp"], 3578.0); + assert_eq!(to_partial(&p, Target::Rendered)["wb"]["temp"], 3578.0); + assert!(to_partial_report(&p, None, Target::RawAbsolute, 1.5).1.is_empty()); + // an unusable reference (zero) counts as none + let zero = props(&x.replace(r#" crs:Tint="-5""#, r#" crs:Tint="-5" crs:AsShotTemperature="0""#)); + assert_eq!(to_partial(&zero, Target::RawRelative)["wb"], json!({"mode": "asShot"})); + } + + #[test] + fn target_follows_the_file() { + use lightcraft_catalog::MediaKind; + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", false), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "nef", false), Target::RawRelative); + // Lightroom's `fileFormat` name for proprietary raws + assert_eq!(Target::for_file(MediaKind::Raw, "RAW", false), Target::RawRelative); + assert_eq!(Target::for_file(MediaKind::Raw, "DNG", false), Target::RawAbsolute); + // a raw shown from its embedded preview develops like a rendered file + assert_eq!(Target::for_file(MediaKind::Raw, "ARW", true), Target::Rendered); + assert_eq!(Target::for_file(MediaKind::Image, "JPG", false), Target::Rendered); + } + #[test] fn xmp_preset_with_name_and_group() { // Written for this test: a minimal preset-shaped packet with localized name/group. @@ -584,25 +716,38 @@ mod tests { } #[test] - fn channel_curves_need_the_master_curve() { - // Lightroom ignores a red / green / blue curve without `ToneCurvePV2012` - let packet = |master: &str| { + fn channel_curves_need_the_master_and_all_channel_curves() { + // Lightroom applies red / green / blue curves only when the master and all three are present + let seq = |name: &str, pts: &str| format!("{pts}"); + let identity = "0, 0255, 255"; + let bent = "0, 064, 40255, 255"; + let packet = |curves: &[String]| { format!( r#" - {master} - 0, 064, 40255, 255 - "# + {} + "#, + curves.concat() ) }; - let alone = to_partial(&props(&packet("")), Some(true)); + let partial = |curves: &[String]| to_partial(&props(&packet(curves)), Target::RawAbsolute); + let (master, red, green, blue) = ( + seq("ToneCurvePV2012", identity), + seq("ToneCurvePV2012Red", identity), + seq("ToneCurvePV2012Green", bent), + seq("ToneCurvePV2012Blue", identity), + ); + // a channel curve alone, or with the master but without its two siblings: ignored + let alone = partial(std::slice::from_ref(&green)); assert!(alone.pointer("/curve/green").is_none(), "{alone}"); - let master = "0, 0255, 255"; - let with = to_partial(&props(&packet(master)), Some(true)); - assert!(with.pointer("/curve/green").is_some() && with.pointer("/curve/master").is_some(), "{with}"); + let master_and_green = partial(&[master.clone(), green.clone()]); + assert!(master_and_green.pointer("/curve/green").is_none() && master_and_green.pointer("/curve/master").is_some(), "{master_and_green}"); + // all four: read + let all = partial(&[master, red, green, blue]); + assert!(["/curve/master", "/curve/red", "/curve/green", "/curve/blue"].iter().all(|p| all.pointer(p).is_some()), "{all}"); } fn unmapped(x: &str) -> Vec { - to_partial_report(&props(x), None, None, 1.5).1 + to_partial_report(&props(x), None, Target::Any, 1.5).1 } #[test] @@ -622,8 +767,8 @@ mod tests { let p = props(&preset_packet("", "")); let mut d = DevelopSettings::default(); d.optics.lens_profile = true; - assert!(apply_partial(&d, &to_partial(&p, None), 1.0).optics.lens_profile); + assert!(apply_partial(&d, &to_partial(&p, Target::Any), 1.0).optics.lens_profile); let on = props(&preset_packet("", "").replace(r#"crs:LensProfileEnable="0""#, r#"crs:LensProfileEnable="1""#)); - assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, None), 1.0).optics.lens_profile); + assert!(apply_partial(&DevelopSettings::default(), &to_partial(&on, Target::Any), 1.0).optics.lens_profile); } } diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index feea9234c..b1c987ab0 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -298,7 +298,7 @@ mod tests { fn settings() -> (DevelopSettings, Vec) { let d = lightcraft_meta::parse_xmp(PACKET).unwrap(); - let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), None, 1.5); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); (DevelopSettings::default().merged(&partial).expect("valid settings"), unmapped) } diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 48da21e27..6a89dda88 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -101,14 +101,15 @@ pub fn load(s: &mut Session) { /// functions, so a 24 MP demo original takes minutes of CPU time on a small machine (a 4-core CI /// VM). Each scene at each size is therefore generated once per process and shared: /// -/// - concurrent requests wait for one generation (Before and After at 1:1 both load the -/// original; sessions side by side, e.g. parallel tests, open the same photo); +/// - one generation per scene and size, which concurrent requests wait for (Before and After at +/// 1:1 both load the original; sessions side by side, e.g. parallel tests, open the same photo; +/// how it renders: [`generate`]); /// - the most recently used results are kept, up to [`scene_cache_limit`] bytes. They are the same /// allocations the sessions' source caches hold, so they cost extra memory only once no session /// holds them any more. /// /// Only demo photos use this; real files are decoded by their loader as before. -pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Arc { +pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> Result, String> { let key = SceneKey { kind: scene.kind, seed: scene.seed, width: scene.width, height: scene.height, max_edge }; let slot = { let mut c = scene_cache(); @@ -120,18 +121,152 @@ pub(crate) fn scene_pixels(scene: &lightcraft_scenes::Scene, max_edge: usize) -> e.slot.clone() } None => { - let slot = Arc::new(std::sync::OnceLock::new()); + let slot = Arc::new(SceneSlot::default()); c.entries.push(SceneEntry { key, slot: slot.clone(), used: tick }); slot } } }; - // (no lock held while generating: other scenes and sizes are served meanwhile) - let pixels = slot.get_or_init(|| Arc::new(scene.render_fit(max_edge))).clone(); + let pixels = slot.get_or_generate(|how| match how { + Render::Parallel => scene.render_fit(max_edge), + Render::Serial => scene.render_fit_serial(max_edge), + }); trim_scene_cache(&mut scene_cache(), scene_cache_limit()); pixels } +/// How a generation renders (see [`generate`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Render { + /// On rayon threads that do nothing else. + Parallel, + /// On the calling thread alone, without rayon. + Serial, +} + +const FAILED: &str = "the demo scene could not be generated"; + +/// Render a scene for [`SceneSlot::get_or_generate`], so that it never needs a thread that waits +/// for a generation. A rayon worker renders on its own thread alone: rendering in parallel, it +/// would run queued jobs while it waits for rows other workers took, and one of them could ask for +/// this very scene and wait for the generation further down its own stack (the faces scan's pool +/// threads hung that way). That also keeps the work within the caller's pool and its limits (the +/// scan's pace, `LIGHTCRAFT_FACE_THREADS`). Any other caller (the render workers, which are plain +/// threads) renders in parallel on [`render_pool`], whose threads only ever render, and blocks +/// without running other work meanwhile; where that pool can't be started it renders alone too. +fn generate(render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> lightcraft_raster::Rgb32f { + if rayon::current_thread_index().is_some() { + return render(Render::Serial); + } + match render_pool() { + Some(pool) => pool.install(|| render(Render::Parallel)), + None => render(Render::Serial), + } +} + +/// The threads non-rayon callers' generations render on: rayon's default count (`RAYON_NUM_THREADS`, +/// else the available parallelism), like the global pool they used before, without touching that +/// pool (asking it for its size starts it, which panics where its threads can't start); `None` +/// where this pool's threads can't start. +fn render_pool() -> Option<&'static rayon::ThreadPool> { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + POOL.get_or_init(|| rayon::ThreadPoolBuilder::new().thread_name(|i| format!("lc-demo-scene-{i}")).build().ok()).as_ref() +} + +/// One scene at one size: generated once, then shared. +#[derive(Default)] +struct SceneSlot { + state: std::sync::Mutex, + /// Signalled when a generation ends. + ended: std::sync::Condvar, +} + +#[derive(Default)] +struct SlotState { + generation: Generation, + /// Generations started so far; the one running or last ended is number `started`. + started: u64, + /// Callers waiting for the generation running (for the tests). + waiting: usize, +} + +#[derive(Default)] +enum Generation { + #[default] + Empty, + Running, + Ready(Arc), + /// The last generation failed (its render panicked); the next request tries again. + Failed, +} + +impl SlotState { + /// Start a generation; its number. + fn start(&mut self) -> u64 { + self.started += 1; + self.generation = Generation::Running; + self.started + } + + /// What came of generation `n`, once it has ended: its pixels, or an error when it failed + /// (even if a retry has started or finished since: callers get the outcome they waited for). + fn outcome(&self, n: u64) -> Option, String>> { + match &self.generation { + Generation::Running if self.started == n => None, + // pixels are kept for good once made, so only generation `n` itself can have made them + Generation::Ready(p) if self.started == n => Some(Ok(p.clone())), + _ => Some(Err(FAILED.into())), + } + } +} + +impl SceneSlot { + fn lock(&self) -> std::sync::MutexGuard<'_, SlotState> { + self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn ready(&self) -> Option> { + match &self.lock().generation { + Generation::Ready(p) => Some(p.clone()), + _ => None, + } + } + + /// The pixels `render` makes ([`generate`]), generated once: the first caller renders, with no + /// lock held, and later ones wait for its outcome. A render that panics is an error for the + /// callers of its generation, and the next request tries again. + fn get_or_generate(&self, render: impl FnOnce(Render) -> lightcraft_raster::Rgb32f + Send) -> Result, String> { + let mut g = self.lock(); + let n = match &g.generation { + Generation::Ready(p) => return Ok(p.clone()), + Generation::Running => { + let n = g.started; + g.waiting += 1; + let outcome = loop { + if let Some(o) = g.outcome(n) { + break o; + } + g = self.ended.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner); + }; + g.waiting -= 1; + return outcome; + } + Generation::Empty | Generation::Failed => g.start(), + }; + drop(g); + let made = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| generate(render))); + let mut g = self.lock(); + g.generation = match made { + Ok(pixels) => Generation::Ready(Arc::new(pixels)), + Err(_) => Generation::Failed, + }; + let outcome = g.outcome(n).unwrap_or_else(|| Err(FAILED.into())); + drop(g); + self.ended.notify_all(); + outcome + } +} + /// Bytes of generated scenes kept for reuse: half the memory budget (768 MB by default; enough /// for one 24 MP original and the previews around it). fn scene_cache_limit() -> usize { @@ -147,11 +282,9 @@ struct SceneKey { max_edge: usize, } -type SceneSlot = Arc>>; - struct SceneEntry { key: SceneKey, - slot: SceneSlot, + slot: Arc, used: u64, } @@ -167,7 +300,7 @@ fn scene_cache() -> std::sync::MutexGuard<'static, SceneCache> { } fn scene_bytes(slot: &SceneSlot) -> usize { - slot.get().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>())) + slot.ready().map_or(0, |img| img.data.len().saturating_mul(std::mem::size_of::<[f32; 3]>())) } /// Drop the least recently used generated scenes until the rest fit in `limit` bytes. Scenes still @@ -178,7 +311,7 @@ fn trim_scene_cache(c: &mut SceneCache, limit: usize) { if total <= limit { return; } - let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.get().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else { + let Some(i) = c.entries.iter().enumerate().filter(|(_, e)| e.slot.ready().is_some()).min_by_key(|(_, e)| e.used).map(|(i, _)| i) else { return; }; c.entries.swap_remove(i); @@ -196,12 +329,12 @@ mod tests { #[test] fn a_scene_is_generated_once_per_size_and_shared() { let s = small_scene(); - let a = scene_pixels(&s, 48); - let b = scene_pixels(&s, 48); + let a = scene_pixels(&s, 48).unwrap(); + let b = scene_pixels(&s, 48).unwrap(); assert!(Arc::ptr_eq(&a, &b), "the second request reuses the first generation"); assert_eq!(a.width.max(a.height), 48); assert_eq!(a.data, s.render_fit(48).data, "the same pixels as generating it directly"); - let c = scene_pixels(&s, 40); + let c = scene_pixels(&s, 40).unwrap(); assert_eq!(c.width.max(c.height), 40, "another size is its own generation"); } @@ -210,19 +343,332 @@ mod tests { let s = small_scene(); let edge = 37; // a size no other test asks for let got: Vec<_> = std::thread::scope(|sc| { - let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge))).collect(); + let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| scene_pixels(&s, edge).unwrap())).collect(); hs.into_iter().map(|h| h.join().unwrap()).collect() }); assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); } + /// Run `f` on a thread of its own and wait for it at most `secs`: a deadlock fails the test instead of hanging it. + fn within(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + rx.recv_timeout(std::time::Duration::from_secs(secs)).expect("deadlocked") + } + + /// Wait (at most ten seconds) until `n` callers wait for `slot`'s generation. + fn until_waiting(slot: &SceneSlot, n: usize) { + let started = std::time::Instant::now(); + while slot.lock().waiting < n { + assert!(started.elapsed() < std::time::Duration::from_secs(10), "the callers never got to wait"); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + } + + fn pixels(n: usize) -> lightcraft_raster::Rgb32f { + lightcraft_raster::Rgb32f::new(n, 1) + } + + /// Rayon workers asking for a scene while it is generated never deadlock. The render is parallel, and a rayon worker + /// waiting for rows another worker took runs queued jobs on top of itself: generating on the caller's thread, the + /// faces scan's pool threads ended up waiting on the generation further down their own stack and hung. Requests + /// keep arriving on a four-thread pool while the scene is generated, as when several photos are queued; every one + /// of them must come back, at least four of them must have overlapped the generation, and the test waits with a + /// timeout, so a deadlock fails it instead of hanging. + #[test] + fn rayon_workers_asking_for_a_scene_being_generated_never_deadlock() { + let scene = Arc::new(small_scene()); + let edge = 601; // a size no other test asks for + const ASKED: usize = 24; + let (overlap, sizes) = within(120, move || { + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let issued = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + for _ in 0..ASKED { + let (scene, done, issued) = (scene.clone(), done_tx.clone(), issued.clone()); + issued.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + pool.spawn(move || { + let got = scene_pixels(&scene, edge).unwrap(); + // how many requests had been made when this one came back + let _ = done.send((issued.load(std::sync::atomic::Ordering::SeqCst), got.data.len())); + }); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let replies: Vec<(usize, usize)> = (0..ASKED).map(|_| done_rx.recv().unwrap()).collect(); + (replies.iter().map(|r| r.0).min().unwrap(), replies.iter().map(|r| r.1).collect::>()) + }); + assert_eq!(sizes.len(), ASKED, "every request came back"); + assert!(sizes.iter().all(|n| *n == sizes[0] && *n > 0)); + assert!(overlap >= 4, "requests overlapped the generation (only {overlap} had been made when the first came back)"); + } + + /// However many requests (from rayon workers and other threads) arrive while a scene is generated, it is rendered + /// once, and never twice at the same time: duplicates would each hold a whole image (288 MB for a 24 MP original). + #[test] + fn a_scene_is_rendered_once_however_many_ask() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let (renders, peak) = within(60, || { + 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 = { + let (running, peak, renders) = (running.clone(), peak.clone(), renders.clone()); + move |how: Render| { + let now = running.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now, Ordering::SeqCst); + renders.fetch_add(1, Ordering::SeqCst); + // work like the real render's: in parallel, the rendering thread waits for rows other threads took + use rayon::prelude::*; + let sum: u64 = match how { + Render::Parallel => (0..2_000_000u64).into_par_iter().map(|i| i % 7).sum(), + Render::Serial => (0..2_000_000u64).map(|i| i % 7).sum(), + }; + std::thread::sleep(std::time::Duration::from_millis(100)); + running.fetch_sub(1, Ordering::SeqCst); + pixels(sum as usize % 5 + 1) + } + }; + let pool = rayon::ThreadPoolBuilder::new().num_threads(4).build().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + for _ in 0..16 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + pool.spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + for _ in 0..4 { + let (slot, render, tx) = (slot.clone(), render.clone(), tx.clone()); + std::thread::spawn(move || { + let _ = tx.send(slot.get_or_generate(render).unwrap()); + }); + } + let got: Vec<_> = (0..20).map(|_| rx.recv().unwrap()).collect(); + assert!(got.windows(2).all(|w| Arc::ptr_eq(&w[0], &w[1])), "every request got the same pixels"); + (renders.load(Ordering::SeqCst), peak.load(Ordering::SeqCst)) + }); + assert_eq!((renders, peak), (1, 1)); + } + + /// A rayon worker (the faces scan's) renders a scene on its own thread alone, so the work stays within its pool + /// 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 pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let slot = SceneSlot::default(); + let mut seen = None; + pool.install(|| { + let caller = std::thread::current().id(); + slot.get_or_generate(|how| { + seen = Some((how, caller, std::thread::current().id())); + pixels(1) + }) + .unwrap(); + }); + let other = SceneSlot::default(); + let mut other_seen = None; + other + .get_or_generate(|how| { + other_seen = Some((how, std::thread::current().name().unwrap_or_default().to_string())); + pixels(1) + }) + .unwrap(); + (seen.unwrap(), other_seen.unwrap()) + }); + assert_eq!((how, renderer), (Render::Serial, caller)); + assert_eq!(other_how, Render::Parallel); + assert!(other_name.starts_with("lc-demo-scene-"), "{other_name}"); + } + + /// A caller finding a generation running waits for it instead of rendering its own (one 24 MP original costs + /// minutes of CPU time on a small machine). + #[test] + fn callers_wait_for_the_generation_running() { + let (a, b, renders) = within(30, || { + let slot = Arc::new(SceneSlot::default()); + let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let (started_tx, go_rx) = (Arc::new(std::sync::Mutex::new(started_tx)), Arc::new(std::sync::Mutex::new(go_rx))); + let ask = |slot: &Arc| { + let (slot, renders, started_tx, go_rx) = (slot.clone(), renders.clone(), started_tx.clone(), go_rx.clone()); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + renders.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let _ = started_tx.lock().unwrap().send(()); + let _ = go_rx.lock().unwrap().recv(); + pixels(1) + }) + }) + }; + let first = ask(&slot); + started.recv().unwrap(); + let second = ask(&slot); + until_waiting(&slot, 1); + go.send(()).unwrap(); + let (a, b) = (first.join().unwrap().unwrap(), second.join().unwrap().unwrap()); + (a, b, renders.load(std::sync::atomic::Ordering::SeqCst)) + }); + assert!(Arc::ptr_eq(&a, &b) && a.width == 1, "one generation, shared"); + assert_eq!(renders, 1); + } + + /// A generation that fails (its render panicked) is an error for its callers, never a hang, and the next request + /// 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 slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..2) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 2); + drop(go); + let first = failing.join().unwrap(); + let waiters: Vec<_> = waiters.into_iter().map(|w| w.join().unwrap()).collect(); + (first, waiters, slot.get_or_generate(|_| pixels(3))) + }); + assert!(first.is_err() && waiters.iter().all(Result::is_err), "the callers of the failed generation get its error"); + assert_eq!(again.unwrap().width, 3); + } + + /// Callers get the outcome of the generation they waited for, even when a retry starts (or finishes) before they + /// look: a failed generation followed by a retry is still an error for them. + #[test] + fn a_retry_never_changes_the_outcome_waited_for() { + let mut st = SlotState::default(); + let n = st.start(); + assert!(st.outcome(n).is_none(), "running"); + st.generation = Generation::Failed; + let m = st.start(); + assert!(matches!(st.outcome(n), Some(Err(_)))); + assert!(st.outcome(m).is_none()); + st.generation = Generation::Ready(Arc::new(pixels(1))); + 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 mut wrong = 0; + for _ in 0..20 { + let slot = Arc::new(SceneSlot::default()); + let (started_tx, started) = std::sync::mpsc::channel::<()>(); + let (go, go_rx) = std::sync::mpsc::channel::<()>(); + let failing = { + let slot = slot.clone(); + std::thread::spawn(move || { + slot.get_or_generate(move |_| { + let _ = started_tx.send(()); + let _ = go_rx.recv(); + panic!("render failed"); + }) + }) + }; + started.recv().unwrap(); + let waiters: Vec<_> = (0..3) + .map(|_| { + let slot = slot.clone(); + std::thread::spawn(move || slot.get_or_generate(|_| pixels(2))) + }) + .collect(); + until_waiting(&slot, 3); + let retry = { + let slot = slot.clone(); + std::thread::spawn(move || { + loop { + if matches!(slot.lock().generation, Generation::Failed) { + return slot.get_or_generate(|_| pixels(4)); + } + std::hint::spin_loop(); + } + }) + }; + drop(go); + let _ = failing.join().unwrap(); + wrong += waiters.into_iter().map(|w| w.join().unwrap()).filter(Result::is_ok).count(); + assert!(retry.join().unwrap().is_ok()); + } + wrong + }); + assert_eq!(wrong, 0, "waiters of a failed generation got a retry's pixels"); + } + + /// Generating a scene never touches the global rayon pool, so a process whose global pool couldn't start (asking it + /// anything then panics) still gets its demo scenes. Run in a process of its own: the global pool is the process's. + #[test] + fn scenes_are_generated_when_the_global_pool_is_broken() { + use std::io::Read; + use std::process::Stdio; + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "demo::tests::with_a_broken_global_pool", "--ignored", "--test-threads=1"]) + .env("LC_DEMO_BROKEN_GLOBAL_POOL", "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + // Drain both pipes on threads so a chatty child can't block on a full pipe. + let drain = |mut r: Box| { + std::thread::spawn(move || { + let mut s = String::new(); + let _ = r.read_to_string(&mut s); + s + }) + }; + let (stdout, stderr) = (drain(Box::new(child.stdout.take().unwrap())), drain(Box::new(child.stderr.take().unwrap()))); + // A deadlocked child fails the test instead of hanging CI. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("the child process did not finish within 120 s"); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let (stdout, stderr) = (stdout.join().unwrap(), stderr.join().unwrap()); + assert!(status.success() && stdout.contains("1 passed"), "{stdout}\n{stderr}"); + } + + #[test] + #[ignore = "run in a process of its own by scenes_are_generated_when_the_global_pool_is_broken"] + fn with_a_broken_global_pool() { + if std::env::var_os("LC_DEMO_BROKEN_GLOBAL_POOL").is_none() { + return; + } + let broken = rayon::ThreadPoolBuilder::new().spawn_handler(|_| Err(std::io::Error::other("no threads"))).build_global(); + assert!(broken.is_err()); + assert!(std::panic::catch_unwind(rayon::current_num_threads).is_err(), "the global pool is broken"); + let s = small_scene(); + let got = scene_pixels(&s, 33).unwrap(); + assert_eq!(got.data, s.render_fit_serial(33).data); + } + #[test] fn the_cache_keeps_the_most_recent_scenes_within_its_limit() { let key = |max_edge| SceneKey { kind: lightcraft_scenes::Kind::Dunes, seed: 1, width: 3, height: 2, max_edge }; let entry = |max_edge, used, px: Option| { - let slot: SceneSlot = Arc::new(std::sync::OnceLock::new()); + let slot = Arc::new(SceneSlot::default()); if let Some(n) = px { - let _ = slot.set(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); + slot.lock().generation = Generation::Ready(Arc::new(lightcraft_raster::Rgb32f::new(n, 1))); } SceneEntry { key: key(max_edge), slot, used } }; diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs new file mode 100644 index 000000000..f47e099b6 --- /dev/null +++ b/crates/engine/src/display.rs @@ -0,0 +1,176 @@ +//! The monitor profile (`app.displayProfile`): previews shown on a wide-gamut or calibrated +//! display through its ICC profile. +//! +//! The frontend holds the profile in use (Settings ▸ Display, the UI command `app.displayProfile`). +//! With a profile, the loupe renders into the display's own primaries +//! ([`RenderJob::with_display`](crate::media::RenderJob::with_display)) and everything else made +//! for sRGB (thumbnails, cached previews, embedded camera JPEGs) is converted to it +//! ([`present`]). Histograms and the preview caches stay sRGB, and exports never use it. + +pub use lightcraft_codecs::display::DisplayKind; +use lightcraft_codecs::display::{DisplayProfile, MAX_PROFILE_BYTES}; +use lightcraft_pipeline::DisplaySpace; +use lightcraft_raster::{Histogram, Rgba8}; +use serde_json::{Value, json}; +use std::sync::Arc; + +use crate::media::RenderResult; + +/// A loaded display profile: its transforms and the pipeline target. +#[derive(Debug)] +pub struct Display { + /// The file it came from. + pub path: String, + pub profile: DisplayProfile, + pub space: DisplaySpace, +} + +impl Display { + /// Load the ICC profile at `path`. + pub fn load(path: &str) -> Result { + let path = path.trim(); + let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; + if meta.len() > MAX_PROFILE_BYTES as u64 { + return Err(format!("{path}: too large for an ICC profile")); + } + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + Display::from_bytes(path, &bytes) + } + + pub fn from_bytes(path: &str, bytes: &[u8]) -> Result { + let profile = DisplayProfile::from_icc(bytes).map_err(|e| format!("{path}: {e}"))?; + let space = DisplaySpace::new(profile.to_rec2020, profile.id).ok_or_else(|| format!("{path}: the profile's primaries are degenerate"))?; + Ok(Display { path: path.to_string(), profile, space }) + } + + pub fn id(&self) -> u64 { + self.profile.id + } + + /// Load the profile at `path` (`""`: none, the display is treated as sRGB). + pub fn load_opt(path: &str) -> Result>, String> { + if path.trim().is_empty() { Ok(None) } else { Display::load(path).map(|d| Some(Arc::new(d))) } + } + + /// For `app.displayProfile` and Settings: path, description, kind, primaries (xy). + pub fn describe(&self) -> Value { + let to_xyz = lightcraft_color::REC2020.to_xyz().mul(&self.profile.to_rec2020).0; + let xy = |c: usize| { + let (x, y, z) = (to_xyz[0][c], to_xyz[1][c], to_xyz[2][c]); + let s = x + y + z; + if s.abs() < 1e-12 { json!([0.0, 0.0]) } else { json!([round4(x / s), round4(y / s)]) } + }; + json!({ + "path": self.path, + "description": self.profile.description, + "kind": self.profile.kind.label(), + "primaries": {"red": xy(0), "green": xy(1), "blue": xy(2)}, + }) + } +} + +fn round4(v: f64) -> f64 { + (v * 1e4).round() / 1e4 +} + +/// Make `r` ready for `display`: an sRGB image (thumbnail, cached preview, camera JPEG) is +/// converted to the display's values. A render made for the display (see +/// [`RenderJob::with_display`](crate::media::RenderJob::with_display)) is already. +pub fn present(r: &mut RenderResult, display: &Display) { + if r.display.is_some() { + return; + } + if let Ok(rendered) = &mut r.rendered { + match display.profile.from_srgb(&mut rendered.image) { + Ok(()) => r.display = Some(display.id()), + Err(e) => log::warn!("display profile: {e}"), + } + } +} + +/// A render into `display`'s primaries (sRGB curve) → in place, the display's device values; and +/// its sRGB histogram, plus (`full_srgb`: for the cached view preview) its whole sRGB version. +/// The histogram samples the same pixels [`Histogram::of_srgb8`] would, so only those are +/// converted when the sRGB image itself isn't needed (slider drags). +pub(crate) fn finish_render(image: &mut Rgba8, display: &Display, full_srgb: bool) -> (Option, Option) { + let warn = |e: String| log::warn!("display profile: {e}"); + let (srgb, histogram) = if full_srgb { + let srgb = display.profile.to_srgb(image).map_err(warn).ok(); + let h = srgb.as_ref().map(Histogram::of_srgb8); + (srgb, h) + } else { + let sample = histogram_sample(image); + (None, display.profile.to_srgb(&sample).map_err(warn).ok().map(|s| Histogram::of_srgb8(&s))) + }; + if let Err(e) = display.profile.correct(image) { + warn(e); + } + (srgb, histogram) +} + +/// The pixels [`Histogram::of_srgb8`] reads (at most ~1 MP, a regular grid), as an image of +/// their own (which it then reads whole). +fn histogram_sample(img: &Rgba8) -> Rgba8 { + let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1); + if step == 1 { + return img.clone(); + } + let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + let mut data = Vec::with_capacity(w * h); + for y in (0..img.height).step_by(step) { + for x in (0..img.width).step_by(step) { + data.push(img.get(x, y)); + } + } + Rgba8 { width: w, height: h, data } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p3() -> Display { + Display::from_bytes("p3.icc", &lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap() + } + + fn picture(w: usize, h: usize) -> Rgba8 { + Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i * 7 % 256) as u8, (i * 13 % 256) as u8, (i * 29 % 256) as u8, 255]).collect() } + } + + /// The sampled histogram of a drag render is exactly the histogram of the whole sRGB image. + #[test] + fn sampled_histogram_matches_the_full_one() { + let d = p3(); + for (w, h) in [(40, 30), (1500, 1100), (2561, 1709)] { + let img = picture(w, h); + let (mut a, mut b) = (img.clone(), img.clone()); + let (srgb, full) = finish_render(&mut a, &d, true); + let (none, sampled) = finish_render(&mut b, &d, false); + assert!(srgb.is_some() && none.is_none()); + assert_eq!(full.unwrap(), sampled.unwrap(), "{w}x{h}"); + assert_eq!(a, b); + } + } + + #[test] + fn load_reports_and_rejects() { + let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let good = dir.join("p3.icc"); + std::fs::write(&good, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap(); + let bad = dir.join("bad.icc"); + std::fs::write(&bad, b"not a profile").unwrap(); + + let d = Display::load_opt(&good.to_string_lossy()).unwrap().unwrap(); + let r = d.describe(); + assert_eq!(r["kind"], "matrix"); + assert_eq!(r["description"], "Display P3"); + let red = r["primaries"]["red"].as_array().unwrap(); + assert!((red[0].as_f64().unwrap() - 0.68).abs() < 2e-3 && (red[1].as_f64().unwrap() - 0.32).abs() < 2e-3, "{r}"); + assert!(Display::load_opt(" ").unwrap().is_none()); + let e = Display::load(&bad.to_string_lossy()).unwrap_err(); + assert!(e.contains("not a valid ICC profile"), "{e}"); + assert!(Display::load(&dir.join("missing.icc").to_string_lossy()).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 33366285e..6a9e9147c 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -600,6 +600,11 @@ pub struct ExportOptions { /// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the /// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`]. pub bit_depth: Option, + /// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR + /// rendition plus a gain map, so the file looks right everywhere; quality as set, `limit_kb` + /// not applied), AVIF as 10-bit Rec. 2020 PQ, 32-bit float TIFF with the highlights above SDR + /// white kept. Other formats, and photos without an HDR edit, are SDR. + pub hdr: bool, } impl Default for ExportOptions { @@ -623,6 +628,7 @@ impl Default for ExportOptions { watermark: None, color_space: OutputSpace::Srgb, bit_depth: None, + hdr: false, } } } @@ -658,6 +664,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "watermark", "colorSpace", "bitDepth", + "hdr", ]; /// The keys of a `watermark` object ([`Watermark`], camelCase). @@ -789,7 +796,7 @@ impl ExportOptions { } serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; } - "dontEnlarge" | "removeLocation" | "background" => { + "dontEnlarge" | "removeLocation" | "background" | "hdr" => { boolean(k, v)?; } "naming" | "subfolder" | "path" | "dir" | "preset" => { @@ -922,6 +929,7 @@ impl ExportOptions { .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), + hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr), } } @@ -990,8 +998,10 @@ impl ExportOptions { (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, (ExportFormat::Png, _) => OutputDepth::U8, (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, + (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, (ExportFormat::Avif, _) => OutputDepth::U8, } @@ -1007,9 +1017,19 @@ impl ExportOptions { } } - /// The colour space the file is actually written in (AVIF: sRGB). + /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map), AVIF + /// (PQ) or 32-bit float TIFF with [`ExportOptions::hdr`]. + pub fn hdr_output(&self) -> bool { + self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg | ExportFormat::Avif, _) | (ExportFormat::Tiff, Some(32))) + } + + /// The colour space the file is actually written in (AVIF: sRGB, or Rec. 2020 for HDR). pub fn effective_space(&self) -> OutputSpace { - if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space } + match self.format { + ExportFormat::Avif if self.hdr => OutputSpace::Rec2020, + ExportFormat::Avif => OutputSpace::Srgb, + _ => self.color_space, + } } /// Output file name for photo `p` at 1-based position `seq` in a batch (the original's @@ -1119,13 +1139,8 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { /// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP. pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let mut img = img.clone(); - output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let img = finish_8bit(img, o); let space = o.effective_space(); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; - draw_watermark(&mut img, &wm); - } let profile = icc::write_named(named_space(space)); let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); @@ -1168,6 +1183,56 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.map_err(|e| e.to_string()) } +/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. +fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { + let mut img = img.clone(); + output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let space = o.effective_space(); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; + draw_watermark(&mut img, &wm); + } + img +} + +/// Output sharpening and the watermark on a high-bit-depth render. +fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { + let mut img = img.clone(); + output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; + draw_watermark_deep(&mut img, &wm); + } + img +} + +/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) +/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured +/// against the base as written, so its 8-bit rounding is handed back by the map. +pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + use lightcraft_codecs::gainmap; + let base = finish_8bit(sdr, o); + let hdr = finish_deep(hdr, o); + let DeepSamples::F32(hv) = &hdr.samples else { + return Err("HDR export needs a float HDR render".into()); + }; + let (w, h) = (base.width, base.height); + if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 { + return Err("HDR and SDR renders differ in size".into()); + } + let space = o.effective_space(); + let trc = space.trc(); + let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect(); + let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0; + let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?; + let profile = icc::write_named(named_space(space)); + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string()) +} + /// The codecs' name of an output space (for its ICC profile). pub fn named_space(s: OutputSpace) -> NamedSpace { match s { @@ -1204,12 +1269,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met /// Encode a high-bit-depth image (see [`encode_rendered`]). pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let mut img = img.clone(); - output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; - draw_watermark_deep(&mut img, &wm); - } + let img = finish_deep(img, o); let profile = match img.samples { DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), DeepSamples::U16(_) => icc::write_named(named_space(img.space)), @@ -1222,27 +1282,21 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), }; - let r = match o.format { - ExportFormat::Png => encode::encode_png(&e, &meta), - ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), - ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), - f => return Err(format!("{f:?} export is 8-bit only")), + let r = match (o.format, &img.samples) { + (ExportFormat::Png, _) => encode::encode_png(&e, &meta), + (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), + (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { + lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) + } + (ExportFormat::Avif, _) => encode::encode_avif(&e, o.quality, 8, &meta), + (f, _) => return Err(format!("{f:?} export is 8-bit only")), }; r.map_err(|e| e.to_string()) } -/// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds. -fn parse_shutter(s: &str) -> Option { - let s = s.trim().trim_end_matches(['s', '"']).trim(); - match s.split_once('/') { - Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?), - None => s.parse().ok(), - } - .filter(|v: &f64| v.is_finite() && *v > 0.0) -} - /// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. -pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option { +/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). +pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { let m = &photo.meta; let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); // copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL @@ -1270,7 +1324,9 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> out.state = text(&m.state); out.country = text(&m.country); } - out.keywords = m.keywords.clone(); + let keywords = catalog.export_keywords(&m.keywords); + out.keywords = keywords.flat; + out.hierarchical_keywords = keywords.hierarchical; out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); out.rating = (photo.rating > 0).then_some(photo.rating as i8); // Pixels are exported upright: orientation is baked in. @@ -1283,7 +1339,7 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> out.lens_model = text(&m.lens); out.focal_length = m.focal_mm.map(f64::from); out.f_number = m.aperture.map(f64::from); - out.exposure_time = parse_shutter(&m.shutter); + out.exposure_time = lightcraft_catalog::parse_shutter_seconds(&m.shutter); out.iso = m.iso; } Some(out) @@ -1324,10 +1380,15 @@ pub struct PreparedExport { work: Work, /// The library's originals, which [`run_batch`] never writes over (shared by a batch). guard: std::sync::Arc, + /// Estimated working memory of [`Self::run`] (bytes), held from [`crate::memory::export_gate`] + /// while it runs beside other photos of a batch. + weight: usize, } struct RenderWork { job: crate::media::RenderJob, + /// The HDR rendition of a gain map JPEG (`job` renders its SDR base). + hdr_job: Option, meta: Option, opts: ExportOptions, } @@ -1373,25 +1434,52 @@ 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 work = if o.format.is_rendered() { + // 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 { + sdr_opts = ExportOptions { hdr: false, ..o.clone() }; + &sdr_opts + } else { + o + }; + let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize); + let (work, weight) = if o.format.is_rendered() { let (w, h) = output_size(p, o); - let meta = export_metadata(p, o); + let meta = export_metadata(p, &session.catalog, o); + let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg; let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; - Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() })) + let weight = render_weight(full_px, long, job.level, w.saturating_mul(h)); + let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None }; + (Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })), weight) } else { let lightcraft_catalog::Source::File { path } = &p.source else { return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); }; - Work::File { + let dng = (o.format == ExportFormat::Dng).then_some(o.dng_compression); + // the file's bytes; a DNG also decodes the raw data (16-bit samples) and writes it again + let weight = (p.file_size as usize).saturating_add(if dng.is_some() { full_px.saturating_mul(6) } else { 0 }); + let work = Work::File { path: path.clone(), read: session.media.file_bytes.clone(), packet: crate::sidecar::sidecar_packet(p, &session.catalog), - dng: (o.format == ExportFormat::Dng).then_some(o.dng_compression), + dng, label: p.file_name.clone(), size: (p.width as usize, p.height as usize), - } + }; + (work, weight) }; - Ok(PreparedExport { photo: id, file_name, work, guard }) + Ok(PreparedExport { photo: id, file_name, work, guard, weight }) +} + +/// Working memory of rendering a photo of `full_px` pixels (long edge `long`) from source level +/// `level` into `out_px` output pixels: the decoded source (linear RGB f32, 12 B/px) plus the +/// pipeline's planes and the encoded output (~36 B per output pixel). Measured: a 24 MP export +/// peaks at ~1.2 GB, one at 2048 px from the 2560 px preview at ~150 MB. +fn render_weight(full_px: usize, long: usize, level: crate::media::SourceLevel, out_px: usize) -> usize { + let edge = level.max_edge().min(long.max(1)); + let scale = edge as f64 / long.max(1) as f64; + let src_px = (full_px as f64 * scale * scale).min(usize::MAX as f64 / 64.0) as usize; + src_px.saturating_mul(12).saturating_add(out_px.saturating_mul(36)) } impl PreparedExport { @@ -1399,9 +1487,16 @@ impl PreparedExport { let file_name = self.file_name; match self.work { Work::Render(w) => { - let RenderWork { job, meta, opts } = *w; + let RenderWork { job, hdr_job, meta, opts } = *w; let r = job.run().rendered?; - let bytes = encode_rendered(&r, &opts, meta.as_ref())?; + let bytes = match hdr_job { + Some(hj) => { + let hr = hj.run().rendered?; + let hdr = hr.deep.ok_or("the HDR render has no float samples")?; + encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())? + } + None => encode_rendered(&r, &opts, meta.as_ref())?, + }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) } Work::File { path, read, packet, dng, label, size } => { @@ -1501,6 +1596,11 @@ fn sidecar_path(main: &str, ext: &str) -> String { /// policy. `progress(done, next file)` is called before each photo; returning false cancels the /// rest. Returns one JSON object per photo: `{path, width, height, bytes, sidecars}`, /// `{skipped: path}` or (unless `stop_on_error`) `{photo, file, error}`. +/// +/// Several photos render side by side ([`export_parallelism`], within the memory of +/// [`crate::memory::export_gate`]), so one photo's decode, encode or GPU wait overlaps another's +/// render (issue #496). Paths, writes, results and `progress` still follow the batch order, and the +/// files are the same as one at a time. pub fn run_batch( items: Vec, o: &ExportOptions, @@ -1510,31 +1610,226 @@ pub fn run_batch( stop_on_error: bool, progress: &mut dyn FnMut(usize, &str) -> bool, ) -> Result, String> { - use serde_json::json; - let join = |a: &str, b: &str| if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) }; - let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join(&to.dir, &o.subfolder) }; - let single = items.len() == 1; - let mut taken = std::collections::HashSet::new(); - let mut out = Vec::new(); - for (i, item) in items.into_iter().enumerate() { - if !progress(i, &item.file_name) { - break; + let lanes = export_parallelism(items.len()); + run_batch_with(items, &mut Placer::new(o, to, write, exists, stop_on_error), progress, lanes) +} + +/// How many photos of a batch of `n` render side by side. Each render already spreads its rows +/// over every core; the others fill the cores while it decodes, encodes or waits for the GPU. +/// One on wasm32 (no threads). +pub fn export_parallelism(n: usize) -> usize { + if cfg!(target_arch = "wasm32") { + return 1; + } + let cores = std::thread::available_parallelism().map_or(1, |c| c.get()); + (cores / 4).clamp(1, 4).min(n.max(1)) +} + +/// [`run_batch`] with `lanes` photos in flight (the calling thread is one of them). +fn run_batch_with( + items: Vec, + placer: &mut Placer<'_>, + progress: &mut dyn FnMut(usize, &str) -> bool, + lanes: usize, +) -> Result, String> { + placer.single = items.len() == 1; + if lanes <= 1 || items.len() <= 1 { + for (i, item) in items.into_iter().enumerate() { + 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) = (item.photo, item.file_name.clone(), item.guard.clone()); - let e = match item.run() { - Ok(e) => e, - Err(err) if stop_on_error => return Err(err), - Err(err) => { - out.push(json!({"photo": photo.0, "file": name, "error": err})); + 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 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(), + ahead: lanes, + }; + std::thread::scope(|sc| { + for k in 1..lanes { + let q = &queue; + // a lane that can't start leaves its photos to the others (and to this thread) + if std::thread::Builder::new().name(format!("export-{k}")).spawn_scoped(sc, move || q.work()).is_err() { + break; + } + } + let placed = || { + for (i, (photo, name, guard)) in heads.into_iter().enumerate() { + if !progress(i, &name) { + break; + } + let e = queue.result(i); + placer.place(photo, name, &guard, e)?; + } + Ok::<_, String>(()) + }; + let r = placed(); + queue.stop(); + r + })?; + Ok(std::mem::take(&mut placer.out)) +} + +/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic +/// becomes that photo's error, not the batch's end. +fn run_gated(item: PreparedExport) -> Result { + let _held = crate::memory::export_gate().acquire(item.weight); + let what = format!("exporting {}", item.file_name); + crate::guard::catch(&what, || item.run()).and_then(|r| r) +} + +/// The photos of a batch shared by its lanes: each lane takes the next one, at most +/// [`Queue::ahead`] past the last placed. That bounds the finished files waiting in memory for +/// their turn too (their working memory is given back to the gate as soon as they are encoded). +struct Queue { + lane: std::sync::Mutex, + cv: std::sync::Condvar, + ahead: usize, +} + +struct Lane { + items: Vec>, + /// The next photo to start. + next: usize, + /// Photos handed to the placer so far. + placed: usize, + stop: bool, + done: std::collections::HashMap>, +} + +impl Lane { + fn take_next(&mut self) -> Option<(usize, PreparedExport)> { + let i = self.next; + let item = self.items.get_mut(i)?.take()?; + self.next += 1; + Some((i, item)) + } +} + +impl Queue { + fn lock(&self) -> std::sync::MutexGuard<'_, Lane> { + self.lane.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn wait<'a>(&self, g: std::sync::MutexGuard<'a, Lane>) -> std::sync::MutexGuard<'a, Lane> { + self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn finish(&self, i: usize, r: Result) { + self.lock().done.insert(i, r); + self.cv.notify_all(); + } + + /// A lane thread: run photos until none is left or the batch stops. + fn work(&self) { + loop { + let mut l = self.lock(); + let (i, item) = loop { + if l.stop || l.next >= l.items.len() { + return; + } + if l.next < l.placed.saturating_add(self.ahead) { + match l.take_next() { + Some(t) => break t, + None => return, + } + } + l = self.wait(l); + }; + drop(l); + self.finish(i, run_gated(item)); + } + } + + /// Photo `i`'s result, waiting for it; run here when no lane has started it yet. + fn result(&self, i: usize) -> Result { + let mut l = self.lock(); + loop { + if let Some(r) = l.done.remove(&i) { + l.placed = i + 1; + drop(l); + self.cv.notify_all(); + return r; + } + if l.next == i { + let Some((_, item)) = l.take_next() else { + return Err("export: photo missing from the batch".into()); + }; + drop(l); + self.finish(i, run_gated(item)); + l = self.lock(); continue; } + l = self.wait(l); + } + } + + fn stop(&self) { + self.lock().stop = true; + self.cv.notify_all(); + } +} + +/// Where a batch's files go: each photo's path (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, + write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + exists: &'a dyn Fn(&str) -> bool, + stop_on_error: bool, +} + +fn join_path(a: &str, b: &str) -> String { + if a.is_empty() { b.to_string() } else { format!("{}/{b}", a.trim_end_matches('/')) } +} + +impl<'a> Placer<'a> { + fn new( + o: &'a ExportOptions, + to: &'a Destination, + write: &'a mut dyn FnMut(&str, &[u8]) -> Result<(), String>, + 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 } + } + + /// 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. + fn place( + &mut self, + photo: lightcraft_catalog::PhotoId, + name: String, + 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, + 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(()); + } }; // 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 to.exact.as_deref().filter(|_| single) { + let path = match self.to.exact.as_deref().filter(|_| self.single) { Some(p) => Ok(p.to_string()), None => { - let path = join(&dir, &e.file_name); + let path = join_path(dir, &e.file_name); + let taken = &self.taken; let busy = |main: &str| group(main).iter().any(|p| taken.contains(p) || exists(p)); if !busy(&path) { Ok(path) @@ -1542,12 +1837,12 @@ pub fn run_batch( match o.conflict { Conflict::Overwrite => Ok(path), Conflict::Skip => { - out.push(json!({"skipped": path})); - continue; + self.out.push(json!({"skipped": path})); + return Ok(()); } Conflict::Unique => { let (stem, ext) = e.file_name.rsplit_once('.').map_or((e.file_name.as_str(), None), |(a, b)| (a, Some(b))); - let name = |n: usize| join(&dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); + let name = |n: usize| join_path(dir, &ext.map_or(format!("{stem}-{n}"), |x| format!("{stem}-{n}.{x}"))); (2..1_000_000).map(name).find(|p| !busy(p)).ok_or_else(|| format!("{path}: no free file name")) } } @@ -1555,6 +1850,7 @@ pub fn run_batch( } }; let file = path.clone().unwrap_or_else(|_| name.clone()); + let write = &mut *self.write; let written = path.and_then(|path| { let files = group(&path); // never over an original, whatever the conflict policy or the exact path said @@ -1571,14 +1867,14 @@ pub fn run_batch( }); match written { Ok((path, files, sidecars)) => { - taken.extend(files); - out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); + self.taken.extend(files); + self.out.push(json!({"path": path, "width": e.width, "height": e.height, "bytes": e.bytes.len(), "sidecars": sidecars})); } - Err(err) if stop_on_error => return Err(err), - Err(err) => out.push(json!({"photo": photo.0, "file": file, "error": err})), + Err(err) if self.stop_on_error => return Err(err), + Err(err) => self.out.push(json!({"photo": photo.0, "file": file, "error": err})), } + Ok(()) } - Ok(out) } #[cfg(test)] @@ -1626,9 +1922,28 @@ mod tests { assert!(img.data[3][0] < 80 && img.data[4][0] > 170); } + /// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` + /// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of + /// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. + #[test] + fn exported_keywords_follow_their_options() { + use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo}; + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); + p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()]; + let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap(); + assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]); + assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]); + let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap(); + let back = lightcraft_meta::extract(&jpg); + assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone())); + } + #[test] fn metadata_policies() { - use lightcraft_catalog::{Photo, PhotoId, Source}; + use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); p.meta.camera = "Synthetic X2".into(); p.meta.copyright = "(c) Me".into(); @@ -1637,21 +1952,21 @@ mod tests { p.meta.copyright_url = "https://example.com/rights".into(); p.meta.shutter = "1/250".into(); p.meta.gps = Some((43.0, -110.0)); - let all = export_metadata(&p, &ExportOptions::default()).unwrap(); + let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap(); assert_eq!(all.model.as_deref(), Some("Synthetic X2")); assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9); assert!(all.gps.is_some()); let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() }; - let m = export_metadata(&p, &o).unwrap(); + let m = export_metadata(&p, &Catalog::new(), &o).unwrap(); assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); - let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); + let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); // "copyright only" keeps all the copyright info: status, usage terms, info URL assert_eq!( (c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()), (Some(true), Some("Editorial use only"), Some("https://example.com/rights")) ); - assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); + assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); // embedded and readable back from the JPEG let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); let back = lightcraft_meta::extract(&jpg); @@ -2225,4 +2540,103 @@ mod tests { assert!(WATERMARK_SIZE_RANGE.0 <= 0.01 && WATERMARK_SIZE_RANGE.1 >= 0.15); assert!(WATERMARK_IMAGE_WIDTH_RANGE.0 <= 0.02 && WATERMARK_IMAGE_WIDTH_RANGE.1 >= 1.0); } + + /// A batch with `lanes` photos in flight into a fake folder `out` (nothing exists there): + /// results, writes (path, bytes) and progress calls; `progress` returns false at `cancel_at`. + #[allow(clippy::type_complexity)] + fn lanes_batch( + s: &mut crate::Session, + ids: &[lightcraft_catalog::PhotoId], + o: &ExportOptions, + lanes: usize, + stop_on_error: bool, + cancel_at: usize, + ) -> (Result, String>, Vec<(String, Vec)>, Vec<(usize, String)>) { + let items = prepare_batch(s, ids, o).unwrap(); + let to = Destination { dir: "out".into(), exact: None }; + let mut written = Vec::new(); + let mut write = |p: &str, b: &[u8]| { + written.push((p.to_string(), b.to_vec())); + Ok(()) + }; + let mut seen = Vec::new(); + let mut progress = |i: usize, name: &str| { + seen.push((i, name.to_string())); + i < cancel_at + }; + let files = run_batch_with(items, &mut Placer::new(o, &to, &mut write, &|_| false, stop_on_error), &mut progress, lanes); + (files, written, seen) + } + + /// The files of a batch without their byte counts: a GPU render (when another test turns the + /// GPU off mid-run) may differ from a CPU one by an LSB, never in name, size or order. + fn shape(files: &[serde_json::Value]) -> Vec { + files.iter().map(|f| json!({"path": f["path"], "width": f["width"], "height": f["height"], "error": f["error"]})).collect() + } + + // Issue #496: photos rendered side by side give the same files, names, order and progress as + // one at a time, and a cancel stops at the same photo + #[test] + fn side_by_side_batches_match_one_at_a_time() { + let mut s = crate::Session::with_demo(); + let ids: Vec<_> = s.visible().iter().copied().take(5).collect(); + // one name for all: the Unique numbering follows the order the files are placed in + let o = ExportOptions::from_json(&json!({"format": "png", "width": 40, "naming": "same"})); + let (files, written, seen) = lanes_batch(&mut s, &ids, &o, 1, true, usize::MAX); + let files = files.unwrap(); + let paths: Vec<_> = written.iter().map(|(p, _)| p.clone()).collect(); + assert_eq!(paths, ["out/same.png", "out/same-2.png", "out/same-3.png", "out/same-4.png", "out/same-5.png"]); + let heights: std::collections::HashSet<_> = files.iter().map(|f| f["height"].as_u64()).collect(); + assert!(heights.len() > 1, "photos of different shapes show the order: {files:?}"); + for lanes in [2, 3, 8] { + let (f, w, sn) = lanes_batch(&mut s, &ids, &o, lanes, true, usize::MAX); + assert_eq!(shape(&f.unwrap()), shape(&files), "{lanes} lanes"); + assert_eq!(w.iter().map(|(p, _)| p.clone()).collect::>(), paths, "{lanes} lanes"); + assert_eq!(sn, seen, "{lanes} lanes"); + } + let (f, w, sn) = lanes_batch(&mut s, &ids, &o, 3, true, 2); + assert_eq!(shape(&f.unwrap()), shape(&files[..2])); + assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written"); + } + + // A photo that fails is reported in its place while the others are exported; with + // stop_on_error the batch ends there and nothing after it is written + #[test] + fn side_by_side_batches_report_failures_in_order() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut s = crate::Session::with_demo(); + let mut ids: Vec<_> = s.visible().iter().copied().take(3).collect(); + let gone = + Photo::new(PhotoId(9_496), Source::File { path: "no/such/dir/gone.jpg".into() }, "gone.jpg", "JPEG", 600, 400, "2026-10-09T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(gone) }).unwrap(); + ids.insert(1, PhotoId(9_496)); + let o = ExportOptions::from_json(&json!({"format": "jpeg", "width": 40})); + let (files, written, _) = lanes_batch(&mut s, &ids, &o, 3, false, usize::MAX); + let files = files.unwrap(); + assert_eq!(files.len(), 4); + assert_eq!((files[1]["photo"].as_u64(), files[1]["file"].as_str()), (Some(9_496), Some("gone.jpg")), "{files:?}"); + assert!(files[1]["error"].is_string()); + assert!([0, 2, 3].iter().all(|&i| files[i]["path"].is_string()), "{files:?}"); + assert_eq!(written.len(), 3); + let one = lanes_batch(&mut s, &ids, &o, 1, false, usize::MAX).0.unwrap(); + assert_eq!(shape(&files), shape(&one)); + + let (r, written, _) = lanes_batch(&mut s, &ids, &o, 3, true, usize::MAX); + assert!(r.is_err()); + assert_eq!(written.len(), 1, "only the photo before the failure"); + } + + #[test] + fn export_weights_follow_the_source_and_output_sizes() { + use crate::media::SourceLevel; + let mb = |b: usize| b >> 20; + // a full-size 24 MP export (measured peak ~1.2 GB) + assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Full, 6000 * 4000)), 1098); + // 2048 px from the 2560 px preview (measured ~150 MB) + assert_eq!(mb(render_weight(6000 * 4000, 6000, SourceLevel::Preview, 2048 * 1365)), 145); + // a photo smaller than the preview level is its own source + assert_eq!(render_weight(1000 * 500, 1000, SourceLevel::Preview, 0), 1000 * 500 * 12); + assert_eq!(render_weight(0, 0, SourceLevel::Full, 0), 0); + assert!(render_weight(usize::MAX, 1, SourceLevel::Full, usize::MAX) > 0, "no overflow"); + } } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 572069224..385d5b50d 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -37,7 +37,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { usage_terms: m.usage_terms.clone().unwrap_or_default(), copyright_url: m.copyright_url.clone().unwrap_or_default(), creator: m.artist.clone().unwrap_or_default(), - keywords: m.keywords.clone(), + // 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(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) @@ -151,8 +152,9 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { }); } let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?; - if !fmt.can_decode() { - return Err(format!("{fmt:?} files are not supported yet")); + if let Some(why) = fmt.not_decodable() { + // e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft" + return Err(format!("{fmt:?} files can't be opened: {why}")); } // headers only (issue #367: decoding the pixels was nearly all of an import's CPU time) let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?; @@ -200,6 +202,86 @@ fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result { + /// White-balance multipliers (as shot). + pub wb: [f32; 3], + /// White-balanced camera RGB → linear Rec.2020: the camera matrix, or a look fitted to the camera's JPEG on top. + pub matrix: lightcraft_color::Mat3, + /// Baseline exposure, as a gain. + pub gain: f32, + /// A DNG profile's hue/saturation map and look table. + pub tables: Option<&'a lightcraft_raw::profile::ProfileTables>, + /// A fitted camera look's hue/saturation table. + pub hue_sat: Option<&'a crate::camera_preview::HueSat<'a>>, +} + +impl CameraColourModel<'_> { + /// Rebuild sensor-clipped channels; fully clipped highlights become [`clipped_white`] of the matrix, so they + /// come out of [`Self::apply`] neutral. + pub fn rebuild_highlights(&self, img: &mut Rgb32f) { + lightcraft_raw::highlight::reconstruct_with(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix)); + } + + /// 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)) + }); + } +} + +/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders +/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out +/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a +/// camera profile often tints it by a few per cent, which any darkening of the highlights would show). +/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not +/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per +/// cent), or one that isn't neutral through the `f32` matrix the decode applies. +pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] { + const CAMERA: [f32; 3] = [1.0; 3]; + /// Largest condition number (max row sum norm) of a model trusted for this. + const CONDITION: f64 = 1e3; + /// Largest factor between the answer and the camera's neutral, per channel. + const SPREAD: f64 = 4.0; + let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::()).fold(0.0, f64::max); + let Some(inverse) = model.inverse() else { return CAMERA }; + let condition = norm(model) * norm(&inverse); + if !condition.is_finite() || condition > CONDITION { + return CAMERA; + } + let luma = |v: [f64; 3]| { + let c = model.apply(v); + (0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::() + }; + let n = inverse.apply([1.0; 3]); + let (target, own) = (luma([1.0; 3]), luma(n)); + if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) { + return CAMERA; + } + let white = n.map(|v| v * target / own); + if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) { + return CAMERA; + } + let white = white.map(|v| v as f32); + // neutral through the matrix as the decode applies it + let m = model.to_f32(); + let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]); + let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2])); + if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA } +} + /// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels: /// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the /// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the @@ -303,32 +385,24 @@ fn load_bytes_now( let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height); // the samples aren't needed any more (the colour model below reads only the tags) raw.data = lightcraft_raw::RawData::U16(Vec::new()); - let wb = t.wb; - let m = camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix).to_f32(); let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new); - let gain = 2f32.powf(t.baseline_exposure as f32); // A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6. let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)); + let colour = CameraColourModel { + wb: t.wb, + matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix), + gain: 2f32.powf(t.baseline_exposure as f32), + tables: tables.as_ref(), + hue_sat: hue_sat.as_ref(), + }; // camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied, // fitted to `max_edge` and upright (the same for the plain and the denoised picture) let finish = |mut img: Rgb32f| { let mut stages = vec![("develop", t0.elapsed())]; stages.push(("transform", t0.elapsed())); - lightcraft_raw::highlight::reconstruct(&mut img, wb, HIGHLIGHT_CLIP); + colour.rebuild_highlights(&mut img); stages.push(("highlights", t0.elapsed())); - 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 &tables { - Some(tables) => tables.apply(rgb, gain), - None => rgb.map(|v| v * gain), - }; - hue_sat.as_ref().map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) - }); + colour.apply(&mut img); stages.push(("colour", t0.elapsed())); let img = fit(&img, max_edge, max_edge, Filter::Box); stages.push(("fit", t0.elapsed())); @@ -588,6 +662,19 @@ impl crate::Session { #[cfg(test)] 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 imported_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], + hierarchical_keywords: vec!["travel|Italy|Rome".into()], + ..Default::default() + }; + assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); + } + use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; @@ -869,6 +956,240 @@ mod tests { assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before)); } + /// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's + /// own white-balanced neutral comes out yellow (blue 12 % low). + fn yellowing_look() -> lightcraft_color::Mat3 { + lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) + } + + #[test] + fn clipped_white_renders_neutral_through_the_colour_model() { + use lightcraft_color::{LUMA_2020, Mat3}; + let look = yellowing_look(); + let white = clipped_white(&look); + let out = look.apply(white.map(f64::from)); + assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}"); + let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::(); + assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral"); + assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]); + // a model that can't be inverted, or only just, keeps the camera's neutral + assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // so does one whose white would overflow the rebuilt highlights (2.5e38 in red here) + assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // or move the camera's neutral implausibly far + assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]); + // what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite + for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] { + let w = clipped_white(&model); + let m = model.to_f32(); + let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]); + assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}"); + let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None }; + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0)); + } + } + + /// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that + /// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral; + /// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it. + #[test] + fn clipped_highlights_stay_neutral_when_darkened() { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.4f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None }; + // a grey wall (camera neutral) with a fully clipped light and a light clipped in green only + let mut img = Rgb32f::from_fn(96, 64, |x, y| { + let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + if d(28.0) < 14.0 { + [1.0; 3] + } else if d(70.0) < 12.0 { + [0.6 / wb[0], 1.0, 0.7 / wb[2]] + } else { + [0.3 / wb[0], 0.3, 0.3 / wb[2]] + } + }); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut issue = lightcraft_develop::DevelopSettings::default(); + let l = &mut issue.light; + (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); + let mut h100 = lightcraft_develop::DevelopSettings::default(); + h100.light.highlights = -100.0; + for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] { + let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image; + let p = out.get(28, 32); + assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})"); + let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]); + assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral"); + } + } + + /// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`) + /// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise + /// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last + /// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row. + fn darkened_ramp(rows: std::ops::Range, height: usize) -> ([[u8; 4]; 3], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let w = 1600usize; + let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32; + let sensor = + Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let y = (rows.start + rows.end) / 2; + let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0)) + } + + /// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards + /// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels + /// there. + #[test] + fn rendered_colour_is_continuous_where_the_first_channel_clips() { + let ([a, b, c], worst) = darkened_ramp(0..16, 16); + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels"); + } + + /// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there + /// is no reliable colour around it. It still carries on across the first clip, however thin the line and however + /// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163]; + /// on 512 rows, from [225, 126, 153] to [218, 145, 151]). + #[test] + fn rendered_thin_line_is_continuous_where_the_first_channel_clips() { + let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0); + for height in [32usize, 128, 512, 1024] { + for width in [1usize, 2, 4, 16] { + let top = (height - width) / 2; + let ([a, b, c], worst) = darkened_ramp(top..top + width, height); + let case = format!("{width} rows of {height}"); + assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } + } + } + + /// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1, + /// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale + /// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows + /// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the + /// rendered colour on either side of where red clips on the band's middle row, and the largest step between + /// neighbouring pixels along that row. + fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) { + use lightcraft_pipeline::{RenderRequest, render}; + let wb = [2.5f32, 1.0, 1.6]; + let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None }; + let (w, h) = (320usize, top + strip + band); + let sensor = Rgb32f::from_fn(w, h, |x, y| { + if y < top { + [0.8, 0.35, 0.3] + } else if y < top + strip { + [0.4, 0.95, 0.8] + } else { + [0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8] + } + }); + let y = top + strip + band / 2; + let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap(); + let mut img = sensor.clone(); + colour.rebuild_highlights(&mut img); + colour.apply(&mut img); + let mut s = lightcraft_develop::DevelopSettings::default(); + s.light.exposure = -2.0; + let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image; + let step = |x: usize| { + let (a, b) = (out.get(x - 1, y), out.get(x, y)); + (0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0) + }; + ([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0)) + } + + /// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The + /// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly + /// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then + /// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped + /// (without the coloured surface: [198, 195, 178] then [205, 181, 168]). + #[test] + fn rendered_colour_is_continuous_where_a_second_channel_clips() { + for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] { + let ([a, b], worst) = second_clip(top, strip, band); + let case = format!("{top} rows of colour, {strip} of rim, {band} of band"); + let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0); + assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips"); + assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels"); + } + } + + /// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in + /// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo + /// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them). + #[test] + fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() { + 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(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { + eprintln!("skip: {} absent", dir.display()); + return; + }; + let (img, _) = load_bytes(&bytes, 1600).unwrap(); + let (w, h) = (img.width, img.height); + let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p); + let chroma = |p: [f32; 3]| { + let s = (p[0] + p[1] + p[2]).max(1e-6); + [p[0] / s, p[2] / s] + }; + // the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame + let px = |f: f32, n: usize| (f * n as f32) as usize; + let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]); + let mut rows = 0; + for y in px(0.24, h)..px(0.34, h) { + let row = &img.data[y * w..(y + 1) * w]; + let sky = lum(row[px(0.2, w)]); + let dark: Vec = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect(); + let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue }; + let k = w as f32 / 6000.0; + let mean = |a: usize, b: usize| { + let n = (b - a) as f64; + (a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n]) + }; + let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize); + for (side, (n, f)) in + [(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))] + .into_iter() + .enumerate() + { + for c in 0..2 { + near[side][c] += n[c]; + far[side][c] += f[c]; + } + } + rows += 1; + } + assert!(rows > 50, "the tree was not found ({rows} rows)"); + for side in 0..2 { + let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max); + assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]); + } + } + /// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right /// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky /// heuristic would never look) is used; a matte with an unknown name is ignored. @@ -1167,6 +1488,30 @@ mod tests { assert!(e.contains("without an embedded preview"), "{e}"); } + /// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its + /// headers; loading it says what is wrong instead of naming a bare TIFF compression number. + #[test] + fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + ifd.set(t::COMPRESSION, Value::Short(vec![9])); + ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); + let f = TiffWriter::default().write(&[ifd]).unwrap(); + let p = probe_bytes("GOPR0001.GPR", &f).unwrap(); + assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16)); + let e = load_bytes(&f, 16).unwrap_err(); + assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}"); + } + /// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview; /// CR3 keeps its own wording. #[test] diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 833d92e8e..ac5640a4b 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -33,7 +33,10 @@ use crate::media::ProbeInfo; /// File extensions LightCraft imports (lower case). pub const EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", - "jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "jxl", "gif", "bmp", + // 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", ]; @@ -850,8 +853,17 @@ impl ImportJob { } } - // files a preceding scan already probed are not read again (a network share is slow to read) - let cached: Vec> = todo.iter().map(|f| self.cache.remove(f)).collect(); + // files a preceding scan already probed are not read again (a network share is slow to read), + // as long as they are still there, as they were: a file gone or changed since is probed again + let cached: Vec> = todo + .iter() + .map(|f| { + // (the web's files are not std::fs files: kept as probed) + self.cache + .remove(f) + .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) + }) + .collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); let progress = ScanProgress::default(); let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter(); @@ -899,7 +911,8 @@ impl ImportJob { let packet = crate::sidecar::find_sidecar(&path, self.naming) .and_then(|f| std::fs::read_to_string(f).ok()) .or_else(|| info.xmp.clone().filter(|_| raw)); - let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, raw) { + let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some()); + let sidecar = packet.and_then(|x| match crate::sidecar::parse_sidecar(&x, target) { Ok(sc) => Some(sc), Err(e) => { log::warn!("import {path}: XMP: {e}"); @@ -1044,7 +1057,7 @@ fn name_family(name: &str) -> String { /// Threads for `jobs` file reads (probes, copies) at once. #[cfg(not(target_arch = "wasm32"))] -fn workers(jobs: usize) -> usize { +pub(crate) fn workers(jobs: usize) -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) } @@ -1371,6 +1384,7 @@ impl Session { cache: None, stages: None, view_cache: None, + display: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f6420..22faff70f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,11 +19,13 @@ mod camera_preview; pub mod camera_profiles; pub mod cmd; pub mod config; +pub mod contact_sheet; pub mod crs; pub mod crs_masks; pub mod demo; pub mod denoise; pub mod devices; +pub mod display; pub mod export; pub mod face_download; #[cfg(not(target_arch = "wasm32"))] @@ -53,6 +55,7 @@ pub mod rename; pub mod segment; pub mod sidecar; pub mod smart; +pub mod sync; mod view; pub mod walk; @@ -166,6 +169,9 @@ pub struct Session { /// Photos in the current source with no filter on (cached like `visible`). total: Option<((u64, String), usize)>, pub undo: Vec, + /// Undo steps committed in this session, ever (the history drops its oldest past 1000, so + /// its length can't tell how many steps a command just made; see [`Session::commits`]). + commits: u64, pub redo: Vec, pub interaction: Option, /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on @@ -252,6 +258,9 @@ pub struct Session { pub before: std::collections::HashMap>, /// File probes from the last import review (`library.importPreview`), reused by the import. pub import_probes: std::collections::HashMap, + /// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current + /// (see [`Session::take_folder_changes`]). + pub folder_changes: Option, /// The last (or running) Build Previews. pub preview_build: Option>, /// Develop defaults applied on import (persisted in prefs.json). @@ -290,6 +299,7 @@ impl Session { visible_gen: 0, total: None, undo: Vec::new(), + commits: 0, redo: Vec::new(), interaction: None, skip_auto_write: false, @@ -334,6 +344,7 @@ impl Session { recent_keywords: Vec::new(), before: Default::default(), import_probes: Default::default(), + folder_changes: None, preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, @@ -352,7 +363,10 @@ impl Session { /// Run a command by id. THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; - (spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?; + // a command that validates the photos a call names isn't held back by the selection + if !(spec.explicit_targets && cmd::names_photos(params)) { + (spec.enabled)(self).map_err(|why| EngineError::Disabled(id.to_string(), why))?; + } 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)) @@ -422,6 +436,7 @@ impl Session { let inv = self.catalog.apply(op)?; self.pending_log.push(fwd); self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None }); + self.commits = self.commits.wrapping_add(1); if self.undo.len() > 1000 { self.undo.remove(0); } @@ -486,6 +501,12 @@ impl Session { /// Fold the last `n` undo steps into one (commands that commit step by step because each op /// depends on the state the previous one left). + /// How many undo steps were committed in this session so far: what a command that commits + /// several steps compares before and after to merge them ([`Session::merge_undo`]). + pub fn commits(&self) -> u64 { + self.commits + } + pub fn merge_undo(&mut self, n: usize, label: &str) { if n < 2 || n > self.undo.len() { return; @@ -626,7 +647,8 @@ impl Session { /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the /// other selected photos: only the settings that changed; never spot removal or red eye (they - /// belong to one photo's pixels), nor history / snapshot restores. + /// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor + /// history / snapshot restores. fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec { if !self.auto_sync || self.active() != Some(id) @@ -639,7 +661,7 @@ impl Session { let Some(old) = self.develop_of(id) else { return Vec::new() }; let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() }; if let Some(o) = delta.as_object_mut() { - for k in ["spots", "red_eye", "version"] { + for k in ["spots", "red_eye", "version", "process"] { o.remove(k); } if o.is_empty() { @@ -859,6 +881,8 @@ mod tests_import; #[cfg(test)] mod tests_import_move; #[cfg(test)] +mod tests_keyword_list; +#[cfg(test)] mod tests_libops; #[cfg(test)] mod tests_library; @@ -871,10 +895,14 @@ mod tests_persist; #[cfg(test)] mod tests_prefs; #[cfg(test)] +mod tests_process; +#[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; #[cfg(test)] mod tests_spots; #[cfg(test)] +mod tests_sync; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 6983c2f8b..6c1c7724d 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -478,7 +478,13 @@ fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } } -fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, stored_aspect: f64) -> Result<(Value, Vec), String> { +fn mapped_settings( + text: &str, + target: crate::crs::Target, + aspect: f64, + orientation: Orientation, + stored_aspect: f64, +) -> Result<(Value, Vec), String> { let mut root = crate::preset_import::parse_lua(text)?; let deferred = strip_lua_sentinels(&mut root); let (mut props, values) = crate::preset_import::lua_settings_props(&root)?; @@ -488,7 +494,7 @@ fn mapped_settings(text: &str, raw: bool, aspect: f64, orientation: Orientation, if !props.contains_key("crs:HasCrop") && CROP.iter().any(|k| props.contains_key(*k)) { props.insert("crs:HasCrop".into(), vec!["True".into()]); } - let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), Some(raw), aspect); + let (mut partial, mut unknown) = crate::crs::to_partial_report(&props, Some(&values), target, aspect); catalog_crop(&mut partial, orientation, stored_aspect); if deferred > 0 { unknown.push("Deferred Lightroom adjustments (-999999)".into()); @@ -717,7 +723,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex preserved += 1; } if !src.xmp.is_empty() { - match crate::sidecar::parse_sidecar(&src.xmp, p.kind == lightcraft_catalog::MediaKind::Raw) { + match crate::sidecar::parse_sidecar(&src.xmp, crate::crs::Target::for_photo(&p)) { Ok(mut sc) => { if let Some(crate::sidecar::DevelopPatch::Partial(partial)) = &mut sc.develop { strip_xmp_sentinels(partial); @@ -745,7 +751,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let stored_aspect = number(&src.image, "fileWidth") as f64 / number(&src.image, "fileHeight") as f64; match mapped_settings( &src.settings, - p.kind == lightcraft_catalog::MediaKind::Raw, + crate::crs::Target::for_photo(&p), p.width.max(1) as f64 / p.height.max(1) as f64, orientation, stored_aspect, @@ -936,7 +942,10 @@ mod tests { cyclic.collections[0].insert("parent".into(), json!(10)); assert!(apply(&mut s, cyclic, false).is_err()); assert_eq!(s.catalog.to_snapshot(), before); - assert!(mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), true, 1.0, Orientation::Normal, 1.5).is_err()); + assert!( + mapped_settings(&format!("{}0{}", "{".repeat(1000), "}".repeat(1000)), crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) + .is_err() + ); } #[test] fn compressed_xmp_is_bounded_and_settings_are_data_only() { @@ -946,17 +955,41 @@ mod tests { assert_eq!(xmp(Some(&json!(encoded))).unwrap(), String::from_utf8_lossy(packet)); encoded[0] = 0xff; assert!(xmp(Some(&json!(encoded))).is_err()); - let (partial, _) = - mapped_settings("s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", true, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = mapped_settings( + "s = { Exposure2012 = 0.75, ToneCurvePV2012 = {0,0,128,150,255,255} }", + crate::crs::Target::RawAbsolute, + 1.5, + Orientation::Normal, + 1.5, + ) + .unwrap(); assert_eq!(partial["light"]["exposure"], 0.75); } + #[test] + fn custom_white_balance_on_relative_wb_raws_is_not_read_as_kelvin() { + // Issue #510: a catalog keeps no as-shot white for Custom photos, so on a raw developed + // relative to its as-shot look the edit stays As Shot and is reported; a DNG keeps the Kelvin + let settings = + "s = { WhiteBalance = \"Custom\", Temperature = 3578, Tint = -5, CustomTemperature = 3578.95, CustomTint = -5, Exposure2012 = 0.5 }"; + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawRelative, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!(partial["wb"], json!({"mode": "asShot"}), "{partial}"); + assert_eq!(partial["light"]["exposure"], 0.5); + assert!(unknown.iter().any(|k| k.starts_with("Temperature, Tint")), "{unknown:?}"); + let (partial, unknown) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); + assert_eq!( + (partial["wb"]["mode"].as_str(), partial["wb"]["temp"].as_f64(), partial["wb"]["tint"].as_f64()), + (Some("custom"), Some(3578.0), Some(-5.0)) + ); + assert!(!unknown.iter().any(|k| k.starts_with("Temperature")), "{unknown:?}"); + } + #[test] fn catalog_crops_without_has_crop_are_imported() { // Lightroom Classic catalogs store crop edges and angle but never `HasCrop` let (partial, unknown) = mapped_settings( "s = { CropConstrainAspectRatio = true, CropLeft = 0.042702, CropRight = 0.957298, CropTop = 0.1 }", - true, + crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5, @@ -970,11 +1003,11 @@ mod tests { assert_eq!(crop["angle"], 0.0); assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) - let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); // no crop fields: the crop stays untouched - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Normal, 1.5).unwrap(); assert!(partial.get("crop").is_none()); } @@ -998,7 +1031,7 @@ mod tests { fn catalog_crops_follow_the_photo_orientation() { let crop = |o: Orientation| { let settings = "s = { CropLeft = 0.1, CropRight = 0.6, CropTop = 0.2, CropBottom = 0.9 }"; - let (partial, _) = mapped_settings(settings, true, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); let g = &partial["crop"]["geometry"]; let r = &g["rect"]; [r["x0"].as_f64(), r["y0"].as_f64(), r["x1"].as_f64(), r["y1"].as_f64(), g["angle"].as_f64()].map(|v| (v.unwrap() * 1e9).round() / 1e9) @@ -1011,13 +1044,13 @@ mod tests { assert_eq!(crop(Orientation::FlipH), [0.4, 0.2, 0.9, 0.9, 0.0]); // a mirror turns the straighten the other way let angle = |o: Orientation| { - let (partial, _) = mapped_settings("s = { CropAngle = 2 }", true, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { CropAngle = 2 }", crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); partial["crop"]["geometry"]["angle"].as_f64().unwrap() }; assert_eq!(angle(Orientation::Rotate90), -2.0); assert_eq!(angle(Orientation::FlipH), 2.0); // no crop: nothing to orient - let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", true, 1.5, Orientation::Rotate90, 1.5).unwrap(); + let (partial, _) = mapped_settings("s = { Exposure2012 = 0.5 }", crate::crs::Target::RawAbsolute, 1.5, Orientation::Rotate90, 1.5).unwrap(); assert!(partial.get("crop").is_none()); } @@ -1043,7 +1076,7 @@ mod tests { for o in [Orientation::Normal, Orientation::Rotate90, Orientation::Rotate270, Orientation::Rotate180, Orientation::FlipH] { for angle in [6.5, -6.5] { let g = CropGeometry { angle, ..g }; - let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), true, 1.5, o, 1.5).unwrap(); + let (partial, _) = mapped_settings(&lightroom_crop(g, o, 1.5), crate::crs::Target::RawAbsolute, 1.5, o, 1.5).unwrap(); let got: CropGeometry = serde_json::from_value(partial["crop"]["geometry"].clone()).unwrap(); let close = |a: f64, b: f64| (a - b).abs() < 1e-9; assert!( @@ -1073,7 +1106,7 @@ mod tests { (6100.0, 9150.0), ), ] { - let (partial, _) = mapped_settings(settings, true, 1.5, o, 9504.0 / 6336.0).unwrap(); + let (partial, _) = mapped_settings(settings, crate::crs::Target::RawAbsolute, 1.5, o, 9504.0 / 6336.0).unwrap(); let r: Rect = serde_json::from_value(partial["crop"]["geometry"]["rect"].clone()).unwrap(); let (ow, oh) = if o.swaps_axes() { (6336.0, 9504.0) } else { (9504.0, 6336.0) }; let (w, h) = (r.width() * ow, r.height() * oh); @@ -1112,7 +1145,9 @@ mod tests { assert_eq!(p.develop.light.shadows, 65.0); assert_eq!(p.develop.light.blacks, -22.0); assert!(report["unmapped"]["2"].as_array().unwrap().iter().any(|v| v.as_str().is_some_and(|v| v.contains("Deferred")))); - let (partial, _) = mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", true, 1.0, Orientation::Normal, 1.5).unwrap(); + let (partial, _) = + mapped_settings("s = { Exposure2012 = -5, Contrast2012 = -100 }", crate::crs::Target::RawAbsolute, 1.0, Orientation::Normal, 1.5) + .unwrap(); assert_eq!(partial["light"]["exposure"], -5.0); assert_eq!(partial["light"]["contrast"], -100.0); } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index fcfbbe208..a14093de8 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 = 23; +pub const RENDER_CACHE_VERSION: u64 = 38; /// 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]; @@ -251,7 +251,7 @@ impl SourceRef { pub fn load_source(&self) -> Result { let image = match self { SourceRef::Loaded(a) => return Ok((**a).clone()), - SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge), + SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?, SourceRef::File { path, max_edge, loader, fallback, denoise } => { let plain = || match loader { Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))), @@ -674,6 +674,8 @@ pub struct RenderJob { pub stages: Option>, /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). pub view_cache: Option<(Arc, Hash128)>, + /// Render for this monitor profile ([`Self::with_display`]). + pub display: Option>, } pub struct RenderResult { @@ -687,6 +689,9 @@ pub struct RenderResult { pub loaded: Option, /// Set for a [`QuickJob`]'s stand-in: where the image came from. pub quick: Option, + /// The display profile (id) the image is encoded for; `None`: sRGB (see + /// [`crate::display::present`]). + pub display: Option, } impl RenderJob { @@ -734,6 +739,21 @@ impl RenderJob { self } + /// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready + /// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB. + /// 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 { + return self; + } + let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); + self.key ^= k(&self.display) ^ k(&display); + self.request.display = display.as_ref().map(|d| d.space); + self.display = display; + self + } + pub fn run(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) @@ -749,10 +769,18 @@ impl RenderJob { rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, + display: None, }; } let was_loaded = matches!(self.source, SourceRef::Loaded(_)); - match self.source.load_source() { + // The app is closing (issue #620): nobody is left to show this, and quitting waits for the + // jobs that are running. Without this, one that lost the GPU mid-way would render on the CPU. + let closing = || lightcraft_gpu::shutting_down().then(|| "LightCraft is closing".to_string()); + let source = match closing() { + Some(e) => Err(e), + None => self.source.load_source().and_then(|s| closing().map_or(Ok(s), Err)), + }; + match source { Ok(source) => { // the plain picture, or (Denoise amount above 0 and a denoised picture cached) the mix the amount asks for let src = &source.image_for(self.settings.denoise_amount()); @@ -760,14 +788,30 @@ impl RenderJob { // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); - let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + // for a display profile: the image goes to the display's values, the histogram + // and the cached view preview stay sRGB + let mut srgb = None; + let display = match (&self.display, self.request.display) { + (Some(d), Some(_)) => { + let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; + let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); + if let Some(h) = h { + rendered.histogram = h; + } + srgb = s; + Some(d.id()) + } + _ => None, + }; if let Some((cache, key)) = &self.cache { cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); } if let Some((cache, key)) = &self.view_cache && self.request.quality == Quality::Full + && (display.is_none() || srgb.is_some()) { - cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); + cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } RenderResult { request_id: self.request_id, @@ -778,6 +822,7 @@ impl RenderJob { rendered: Ok(rendered), loaded: (!was_loaded).then_some(source), quick: None, + display, } } Err(e) => RenderResult { @@ -789,6 +834,7 @@ impl RenderJob { rendered: Err(e), loaded: None, quick: None, + display: None, }, } } @@ -853,6 +899,7 @@ impl QuickJob { rendered, loaded: None, quick, + display: None, }; let rendered = |image: Rgba8| { let histogram = Histogram::of_srgb8(&image); @@ -972,6 +1019,7 @@ impl crate::Session { cache, stages: None, view_cache: None, + display: None, }) } @@ -1022,6 +1070,7 @@ impl crate::Session { cache: Some((self.media.rendered.clone(), ck)), stages: None, view_cache: None, + display: None, }) } @@ -1067,6 +1116,7 @@ impl crate::Session { cache: Some((self.media.rendered.clone(), ck)), stages: None, view_cache: None, + display: None, }) } diff --git a/crates/engine/src/memory.rs b/crates/engine/src/memory.rs index d231bf650..8c421668d 100644 --- a/crates/engine/src/memory.rs +++ b/crates/engine/src/memory.rs @@ -18,7 +18,9 @@ use serde::Serialize; /// - a quarter for the working memory of decodes in flight ([`work_gate`]); /// - an eighth for the GPU renderer's pool of recycled buffers (trimmed when the app is idle). /// -/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). Default: +/// The rest is headroom for what isn't cached (renders in progress, textures, the UI). An export +/// batch may, in addition, hold up to twice the budget in photos rendering side by side +/// ([`export_gate`]): transient memory, given back when the batch ends. Default: /// a quarter of the machine's RAM, at most 1.5 GiB (`LIGHTCRAFT_MEMORY_MB` overrides it). pub fn default_budget() -> usize { if let Some(mb) = std::env::var("LIGHTCRAFT_MEMORY_MB").ok().and_then(|v| v.trim().parse::().ok()).filter(|m| *m >= 64) { @@ -83,6 +85,9 @@ fn apply(b: usize) { if let Some(g) = GATE.get() { g.set_limit(b / 4); } + if let Some(g) = EXPORT_GATE.get() { + g.set_limit(export_share(b)); + } lightcraft_gpu::set_pool_limit((b / 8) as u64); } @@ -174,6 +179,19 @@ pub fn work_gate() -> &'static WorkGate { static GATE: OnceLock = OnceLock::new(); +/// The process-wide gate for the photos of an export batch rendering side by side +/// ([`crate::export::run_batch`]; limit: twice the budget). Separate from [`work_gate`], which the +/// decodes inside each export take: one gate held across the other's acquire could deadlock. +pub fn export_gate() -> &'static WorkGate { + EXPORT_GATE.get_or_init(|| WorkGate::new(export_share(budget()))) +} + +fn export_share(budget: usize) -> usize { + budget.saturating_mul(2) +} + +static EXPORT_GATE: OnceLock = OnceLock::new(); + /// Entries and bytes held by one cache. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] pub struct Usage { diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 040d46c3b..044139ff9 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -464,7 +464,7 @@ pub fn group_from_dir(dir: &str) -> Option { } pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: String, group: Option, from_photo: bool) -> Option { - let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), None, crate::crs_masks::DEFAULT_ASPECT); + let (mut settings, unmapped) = crate::crs::to_partial_report(props, Some(values), crate::crs::Target::Any, crate::crs_masks::DEFAULT_ASPECT); if from_photo && let Some(o) = settings.as_object_mut() { // a photo's own framing and absolute white balance don't belong in a look o.remove("crop"); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 7e9249489..e92d193a7 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -114,8 +114,10 @@ pub enum DevelopPatch { Partial(Value), } -/// Parse a sidecar / XMP packet. `raw` selects absolute (raw) vs relative white balance for `crs:`. -pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result { +/// Parse a sidecar / XMP packet. `target` selects how a `crs:` white balance is read +/// ([`crate::crs::Target`]: absolute Kelvin for raws with a measured illuminant, a shift from the +/// as-shot white for everything developed relative to it). +pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Result { let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?; let m = &d.metadata; let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned(); @@ -133,7 +135,8 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result std::result::Result Some(DevelopPatch::Full(Box::new(s))), None if crate::crs::has_adjustments(&d.properties) => { - Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), Some(raw), crate::crs_masks::DEFAULT_ASPECT).0)) + Some(DevelopPatch::Partial(crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT).0)) } None => None, }; @@ -445,6 +448,18 @@ impl Session { /// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem /// sidecar — a raw first, else the first by file name. The others use Full naming /// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other. + /// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once + /// (per photo that is a pass over the whole catalog). + pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec { + let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog)); + ids.iter() + .map(|id| match (self.catalog.photo(*id), &owners) { + (Some(p), Some(o)) => o.naming(p, self.xmp.naming), + _ => self.xmp.naming, + }) + .collect() + } + pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming { match self.catalog.photo(id) { Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming), @@ -475,9 +490,15 @@ impl Session { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; let Some(orig) = file_path(p) else { return Ok(None) }; let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) }; - let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw) - .map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))? - .resolve_label(&self.catalog); + let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?; + Ok(Some((self.sidecar_op(id, sc)?, from))) + } + + /// The op that applies what a sidecar says (already read and parsed, e.g. on a worker + /// thread) to photo `id`: the sidecar wins. No file is read. + pub fn sidecar_op(&self, id: PhotoId, sc: SidecarData) -> Result { + let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; + let sc = sc.resolve_label(&self.catalog); let mut q = (**p).clone(); let develop_changed = merge_into(&mut q, &sc, &(self.clock)()); let mut ops = vec![ @@ -492,7 +513,7 @@ impl Session { if develop_changed { ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File")); } - Ok(Some((Op::Batch { ops }, from))) + Ok(Op::Batch { ops }) } /// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned). @@ -515,6 +536,19 @@ impl Session { #[cfg(test)] mod tests { + /// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`) + /// gives the photo its keywords as paths. + #[test] + fn sidecar_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Lisbon".into(), "Places".into()], + hierarchical_keywords: vec!["Places|Lisbon".into()], + ..Default::default() + }; + let sc = super::parse_sidecar(&lightcraft_meta::write_xmp(&m, None), crate::crs::Target::Rendered).unwrap(); + assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()])); + } + use super::*; use lightcraft_catalog::Meta; @@ -553,7 +587,7 @@ mod tests { fn packet_roundtrip_restores_everything() { let p = photo(); let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new()); - let sc = parse_sidecar(&x, false).unwrap(); + let sc = parse_sidecar(&x, crate::crs::Target::Rendered).unwrap(); assert_eq!(sc.rating, Some(4)); assert_eq!(sc.flag, Some(Flag::Pick)); assert_eq!(sc.label, Some(Some(ColorLabel::Purple))); @@ -572,7 +606,7 @@ mod tests { let x = r#" "#; - let sc = parse_sidecar(x, true).unwrap(); + let sc = parse_sidecar(x, crate::crs::Target::RawAbsolute).unwrap(); assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green)))); let mut p = photo(); let before = p.develop.clone(); diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs new file mode 100644 index 000000000..0a00a59ab --- /dev/null +++ b/crates/engine/src/sync.rs @@ -0,0 +1,702 @@ +//! Synchronize Folder: bring a folder of the library up to date with what is on disk, as +//! Lightroom Classic's Synchronize Folder does. +//! +//! A [scan](scan_with) compares the folder (and the folders inside it) with the library and finds +//! three kinds of change, touching nothing: +//! +//! - **new** files: supported files the library doesn't have, by path or by content (the import +//! scan's duplicate rules, [`crate::import::scan_with`]); +//! - **missing** photos: library photos in the folder whose file is gone (the Missing Photos rule, +//! [`crate::cmd::missing::checked_path`]); +//! - **metadata** updates: photos whose XMP sidecar file was saved after the photo came into the +//! library or was last edited here, and says something the library doesn't (rating, flag, +//! label, metadata, capture time or develop settings). Reading one makes the sidecar win, as +//! Read Metadata from File does. Embedded XMP in a raw is not a sidecar and is not looked at. +//! +//! [`synchronize`] then imports the new files (in place), relinks the moved photos, moves the +//! missing photos to Recently Deleted and reads the metadata updates — each only when chosen — as +//! one undo step. It runs in halves ([`SyncJob`]) so that, in the app, every file it reads is +//! read on a worker thread and only the catalog changes happen on the UI thread — with what every +//! command does after them there: the library's durable save and, with XMP auto-write on, the +//! sidecars of the photos whose metadata was read. +//! +//! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a +//! worker thread: a folder on a sleeping network share must never stall a frame. +//! +//! Limits of telling an update from a sidecar that is merely older: +//! - a sidecar the library itself wrote (auto-write on) agrees with it and is never an update; +//! - the library records when a photo came in and when its develop settings last changed, not +//! when its rating, flag, label or metadata did: a sidecar another app saved, followed by such +//! a change made here (with auto-write off), is listed as an update, and reading it puts the +//! older value back. Reading updates is therefore never the default; +//! - times are compared as the clock the session runs with; headless sessions (CLI, MCP) start +//! with a fixed clock, so their own times say little; +//! - a sidecar is found as Read Metadata from File finds it, including the other naming +//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`). +//! +//! The walk for new files stops at the import's limits on depth and entries (logged), and is +//! not cancelled midway. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source}; +use serde::Serialize; + +use crate::import::{ImportCandidate, ImportMode, ImportOptions, ScanInput, ScanProgress}; +use crate::media::ProbeInfo; +use crate::sidecar::SidecarNaming; +use crate::{EngineError, Result, Session}; + +/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped. +const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024; + +/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the +/// whole scan) and the library's photos checked. +#[derive(Default)] +pub struct SyncProgress { + pub files: ScanProgress, + pub checked: std::sync::atomic::AtomicUsize, + pub to_check: std::sync::atomic::AtomicUsize, +} + +impl SyncProgress { + /// (done, of how many), both halves together. + pub fn counts(&self) -> (usize, usize) { + use std::sync::atomic::Ordering::Relaxed; + let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed)); + (done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed))) + } +} + +/// What a scan of a folder needs, taken from the session so it can run on a worker thread. +pub struct SyncInput { + folder: String, + scan: ScanInput, + /// The library's photos in the folder, each with how its sidecar is named. + photos: Vec<(Arc, SidecarNaming)>, + /// For the colour label names a sidecar may use. + labels: Catalog, +} + +/// What changed in a folder since it came into the library. +#[derive(Clone, Debug, Default, Serialize)] +pub struct FolderChanges { + /// The folder, as asked for. + pub path: String, + /// Files to import. + pub new: Vec, + /// Files whose content the library already has (under another path). + pub duplicates: usize, + /// Supported files that couldn't be read. + pub unreadable: Vec, + /// Photos whose file is gone. + pub missing: Vec, + /// Photos whose sidecar has news. + pub metadata: Vec, + /// Photos whose file was renamed or moved within the folder (found by its content). + pub moved: Vec, + /// The folder itself is not there (moved or renamed whole, or on a disk that isn't + /// connected): nothing else is reported, rather than every photo as missing. + pub offline: bool, + /// The probes of the new files, kept for the import that follows. + #[serde(skip)] + probes: HashMap, + /// The folder's photos as the scan saw them: the scan is current while they are the same + /// records (any change to one replaces it in the catalog). + #[serde(skip)] + seen: Vec>, +} + +/// A photo of the folder and its file. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FolderPhoto { + pub id: u64, + pub path: String, +} + +/// A photo whose file is now somewhere else in the folder. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FolderMoved { + pub id: u64, + pub from: String, + pub to: String, +} + +/// What [`synchronize`] does with the changes found. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SyncChoice { + pub import_new: bool, + pub relink_moved: bool, + pub remove_missing: bool, + pub read_metadata: bool, +} + +impl Default for SyncChoice { + /// Lightroom Classic's defaults: import the new files, leave the rest to be looked at; and + /// photos whose file moved follow it (nothing is lost, and undo puts them back). + fn default() -> Self { + SyncChoice { import_new: true, relink_moved: true, remove_missing: false, read_metadata: false } + } +} + +/// What [`synchronize`] did. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct SyncReport { + pub imported: usize, + pub relinked: usize, + pub removed: usize, + pub read: usize, + /// (path or photo, why) for what couldn't be done. + pub failed: Vec<(String, String)>, +} + +fn invalid(msg: impl Into) -> EngineError { + EngineError::Other(msg.into()) +} + +impl SyncInput { + /// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A + /// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk` + /// (as for Remove Disk from Library: its missing photos can be a whole disk's). + pub fn new(s: &mut Session, path: &str, disk: bool) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(invalid("missing `path`")); + } + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(invalid("choose a folder, not the whole startup disk")); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(invalid(format!("{path}: no photo in the library was imported from it"))); + } + if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) { + return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it"))); + } + let namings = s.sidecar_namings(&ids); + let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect(); + let (scan, _) = ScanInput::new(s, &[path.to_string()]); + Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() }) + } +} + +/// Find what changed in the folder (see the module docs). Reads the disk, never the session. +/// +/// Both halves wait on the disk (a network share answers each file slowly), so they run at once: +/// the look for new files (which probes them on its own threads) beside the checks of the +/// library's photos, which run on several threads too. Stops early when `progress.files.cancel` +/// is set. +pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { + let SyncInput { folder, scan, photos, labels } = input; + let seen: Vec> = photos.iter().map(|(p, _)| Arc::clone(p)).collect(); + if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() { + return FolderChanges { path: folder, seen, offline: true, ..Default::default() }; + } + // (no threads on the web: one after the other there) + #[cfg(target_arch = "wasm32")] + let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress)); + #[cfg(not(target_arch = "wasm32"))] + let (out, checks) = std::thread::scope(|sc| { + let photos = &photos; + let labels = &labels; + let checks = sc.spawn(move || check_photos(photos, labels, progress)); + let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files); + // (a check that panicked counts as nothing found; the scan still reports the rest) + (out, checks.join().unwrap_or_default()) + }); + let mut changes = FolderChanges { path: folder, seen, ..Default::default() }; + for ((p, _), check) in photos.iter().zip(checks) { + let Some(file) = crate::cmd::missing::checked_path(p) else { continue }; + let found = FolderPhoto { id: p.id.0, path: file.to_string() }; + match check { + Check::Missing => changes.missing.push(found), + Check::News => changes.metadata.push(found), + Check::Nothing => {} + } + } + let moved_from = match_moved(&changes.missing, &out.candidates, &out.probes, &labels); + for c in out.candidates { + if moved_from.values().any(|to| *to == c.path) { + continue; + } + let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted); + match (c.duplicate.as_deref(), &c.error) { + // a file only looked at in Local is not in the library yet: it is new + (Some("path"), _) if browsed => changes.new.push(ImportCandidate { duplicate: None, existing: None, ..c }), + (Some("path"), _) => {} + (Some(_), _) => changes.duplicates += 1, + (None, Some(_)) => changes.unreadable.push(c), + (None, None) => changes.new.push(c), + } + } + let missing = std::mem::take(&mut changes.missing); + for m in missing { + match moved_from.get(&m.path) { + Some(to) => changes.moved.push(FolderMoved { id: m.id, from: m.path, to: to.clone() }), + None => changes.missing.push(m), + } + } + changes.probes = out.probes; + changes +} + +/// Whether a photo is the same as far as synchronizing goes: its file, its place in the library +/// and what a sidecar is compared with. Face and pet regions found in the background meanwhile +/// don't make a scan stale. +fn same_for_sync(a: &Arc, b: &Arc) -> bool { + if Arc::ptr_eq(a, b) { + return true; + } + let meta = |p: &Photo| lightcraft_catalog::Meta { regions: Vec::new(), ..p.meta.clone() }; + (&a.source, a.deleted, a.local, a.copy_of, &a.content_hash, a.file_size, a.rating, a.flag, a.label, &a.captured, &a.edited, &a.develop) + == (&b.source, b.deleted, b.local, b.copy_of, &b.content_hash, b.file_size, b.rating, b.flag, b.label, &b.captured, &b.edited, &b.develop) + && meta(a) == meta(b) +} + +/// The content a hash stands for: a Duplicate's own hash is the original's plus `:dup`. +fn base_hash(h: &str) -> &str { + h.split(':').next().unwrap_or(h) +} + +/// Missing photos' files found elsewhere in the folder (old path → new path): the file was +/// renamed or moved, and the photo (with its edits) follows it rather than going missing. +/// +/// By content first: each missing file's hash claims one file with that content (two missing +/// files with the same content claim two). A photo with no hash on record (taken over from a +/// Lightroom catalog) is matched by file size and dimensions, and only when exactly one missing +/// file and exactly one unclaimed file of the folder agree on them. +fn match_moved( + missing: &[FolderPhoto], + candidates: &[ImportCandidate], + probes: &HashMap, + labels: &Catalog, +) -> HashMap { + // distinct missing files (a photo's virtual copies share its file), with what is known of them + let mut files: Vec<(&str, Option<&Photo>)> = Vec::new(); + for m in missing { + if !files.iter().any(|(p, _)| *p == m.path) { + files.push((m.path.as_str(), labels.photo(PhotoId(m.id)).map(|p| &**p))); + } + } + let mut by_hash: HashMap<&str, std::collections::VecDeque<&str>> = HashMap::new(); + for (path, p) in &files { + if let Some(h) = p.and_then(|p| p.content_hash.as_deref()) { + by_hash.entry(base_hash(h)).or_default().push_back(path); + } + } + let usable = |c: &&ImportCandidate| c.error.is_none() && c.duplicate.as_deref() != Some("path"); + let mut moved: HashMap = HashMap::new(); + for c in candidates.iter().filter(usable) { + let hash = probes.get(&c.path).and_then(|i| i.content_hash.as_deref()); + if let Some(from) = hash.and_then(|h| by_hash.get_mut(base_hash(h))).and_then(std::collections::VecDeque::pop_front) { + moved.insert(from.to_string(), c.path.clone()); + } + } + let key = |size: u64, w: u32, h: u32| (size > 0 && w > 0 && h > 0).then_some((size, w, h)); + let mut hashless: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new(); + for (path, p) in &files { + if let Some(p) = p.filter(|p| p.content_hash.is_none()) + && let Some(k) = key(p.file_size, p.width, p.height) + { + hashless.entry(k).or_default().push(path); + } + } + let mut found: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new(); + for c in candidates.iter().filter(usable).filter(|c| !moved.values().any(|to| *to == c.path)) { + if let Some(k) = key(c.file_size, c.width, c.height) { + found.entry(k).or_default().push(c.path.as_str()); + } + } + for (k, from) in hashless { + if let ([from], Some([to])) = (from.as_slice(), found.get(&k).map(Vec::as_slice)) { + moved.insert(from.to_string(), to.to_string()); + } + } + moved +} + +/// What checking one photo of the folder found. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Check { + #[default] + Nothing, + Missing, + News, +} + +fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { + let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing }; + if !Path::new(file).exists() { + Check::Missing + } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) { + Check::News + } else { + Check::Nothing + } +} + +/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the +/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found. +fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec { + use std::sync::atomic::Ordering::Relaxed; + progress.to_check.store(photos.len(), Relaxed); + let cancel = &progress.files.cancel; + let check = |p: &Photo, naming: SidecarNaming| { + let c = check_photo(p, naming, labels); + progress.checked.fetch_add(1, Relaxed); + c + }; + if cfg!(target_arch = "wasm32") { + return vec![Check::Nothing; photos.len()]; + } + #[cfg(not(target_arch = "wasm32"))] + { + let n = crate::import::workers(photos.len()); + if n > 1 { + let next = std::sync::atomic::AtomicUsize::new(0); + let found = std::sync::Mutex::new(vec![Check::Nothing; photos.len()]); + std::thread::scope(|sc| { + for _ in 0..n { + sc.spawn(|| { + while !cancel.load(Relaxed) { + let i = next.fetch_add(1, Relaxed); + let Some((p, naming)) = photos.get(i) else { break }; + let c = check(p, *naming); + if let Some(slot) = found.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { + *slot = c; + } + } + }); + } + }); + return found.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + photos.iter().map(|(p, naming)| if cancel.load(Relaxed) { Check::Nothing } else { check(p, *naming) }).collect() +} + +/// [`scan_with`] on the calling thread. +pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result { + let input = SyncInput::new(s, path, disk)?; + let changes = scan_with(input, &SyncProgress::default()); + if changes.offline { + return Err(not_there(&changes.path)); + } + Ok(changes) +} + +fn not_there(path: &str) -> EngineError { + invalid(format!("{path} is not there (moved, renamed, or on a disk that isn't connected)")) +} + +impl Session { + /// The last scan ([`Session::folder_changes`]) if it is of folder `path` and still current: + /// the folder holds the same photos, none of them changed. What the person was shown is what + /// gets done. Used once. (Changes elsewhere in the library don't matter.) + pub fn take_folder_changes(&mut self, path: &str) -> Option { + let c = self.folder_changes.take()?; + if lightcraft_catalog::query::folder_key(&c.path) != lightcraft_catalog::query::folder_key(path) { + return None; + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let mut now: Vec = self.catalog.query(&f, &Sort::default()); + let mut then: Vec = c.seen.iter().map(|p| p.id).collect(); + now.sort(); + then.sort(); + let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| same_for_sync(p, q))); + unchanged.then_some(c) + } +} + +/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo +/// (imported, or edited here) and says something the library doesn't. +fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool { + let Source::File { path } = &p.source else { return false }; + let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false }; + let Ok(meta) = std::fs::metadata(&file) else { return false }; + if meta.len() > MAX_SIDECAR_BYTES { + return false; + } + let saved = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + let Some(saved) = saved.and_then(|s| i64::try_from(s).ok()) else { return false }; + let seen = [Some(p.imported.as_str()), p.edited.as_deref()].into_iter().flatten().filter_map(lightcraft_catalog::stacks::iso_seconds).max(); + if seen.is_some_and(|t| saved <= t) { + return false; + } + let Ok(text) = std::fs::read_to_string(&file) else { return false }; + let Ok(sc) = crate::sidecar::parse_sidecar(&text, crate::crs::Target::for_photo(p)) else { return false }; + let sc = sc.resolve_label(labels); + let mut q = p.clone(); + crate::sidecar::merge_into(&mut q, &sc, ""); + (q.rating, q.flag, q.label, &q.meta, &q.captured, &q.develop) != (p.rating, p.flag, p.label, &p.meta, &p.captured, &p.develop) +} + +/// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo +/// whose file is back by now is left alone. Once the import is in, a part that fails is +/// reported in `failed` and the rest is still done (and still one step). +pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result { + let (work, mut commit) = SyncJob::start(s, changes, choice)?; + let mut steps = Vec::new(); + work.run(&SyncProgress::default(), |step| { + steps.push(step); + true + }); + for step in steps { + commit.apply(s, step); + } + Ok(commit.finish(s)) +} + +/// Synchronizing in halves, so no file is touched on the UI thread: [`SyncJob::start`] takes what +/// it needs from the session (no file-system call); the [`SyncWork`] does every file read (the +/// import's probes, whether files are still where the scan found them, the sidecars) and hands +/// over [`SyncStep`]s — on a worker thread in the app; [`SyncCommit::apply`] puts each into the +/// catalog and [`SyncCommit::finish`] makes them one undo step. +pub struct SyncJob; + +/// The file-system half of a synchronize (see [`SyncJob`]). +pub struct SyncWork { + /// The import of the new files, and the files. + import: Option<(crate::import::ImportJob, Vec)>, + moved: Vec, + missing: Vec, + /// (photo, its file, how its sidecar is named) + sidecars: Vec<(PhotoId, String, SidecarNaming, crate::crs::Target)>, +} + +/// The catalog half of a synchronize (see [`SyncJob`]). +pub struct SyncCommit { + opts: ImportOptions, + now: String, + /// The library the run was readied for: steps never land in another one (File › Open + /// Library while the work runs), whose photo ids name other photos. + library: Arc<()>, + /// The run's own undo steps so far, and the history's length after the last of them: they + /// are merged only while nothing else came in between (an Undo or an edit made meanwhile + /// stays its own step, and the run's parts stay theirs). + own: usize, + after_last: Option, + interleaved: bool, + report: SyncReport, +} + +/// A piece of work done, for [`SyncCommit::apply`]. +pub enum SyncStep { + /// A batch of new files, readied for the catalog. + Import(crate::import::Prepared), + /// Moved photos whose file is still at its new place (photo, new path). + Relink(Vec<(PhotoId, String)>), + /// Missing photos whose file is still gone. + Remove(Vec), + /// Sidecars read (photo, its file, what the sidecar says or why it couldn't be read). + Sidecars(Vec<(PhotoId, String, std::result::Result)>), +} + +impl SyncJob { + /// Ready a synchronize of `changes` as `choice` says. Reads no file. + pub fn start(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<(SyncWork, SyncCommit)> { + if changes.offline { + return Err(not_there(&changes.path)); + } + let FolderChanges { new, missing, metadata, moved, probes, .. } = changes; + let opts = ImportOptions { mode: ImportMode::Add, ..Default::default() }; + let import = if choice.import_new && !new.is_empty() { + s.import_probes = probes; + let job = crate::import::ImportJob::new(s, opts.clone())?; + Some((job, new.into_iter().map(|c| c.path).collect())) + } else { + None + }; + let now = import.as_ref().map_or_else(|| (s.clock)(), |(j, _)| j.now().to_string()); + let sidecars = if choice.read_metadata { + let namings = s.sidecar_namings(&metadata.iter().map(|m| PhotoId(m.id)).collect::>()); + metadata + .into_iter() + .zip(namings) + .filter_map(|(m, naming)| { + let p = s.catalog.photo(PhotoId(m.id))?; + Some((p.id, m.path, naming, crate::crs::Target::for_photo(p))) + }) + .collect() + } else { + Vec::new() + }; + let work = SyncWork { + import, + moved: if choice.relink_moved { moved } else { Vec::new() }, + missing: if choice.remove_missing { missing } else { Vec::new() }, + sidecars, + }; + let commit = SyncCommit { + opts, + now, + library: Arc::clone(&s.library_identity), + own: 0, + after_last: None, + interleaved: false, + report: SyncReport::default(), + }; + Ok((work, commit)) + } +} + +impl SyncWork { + /// Do the work, handing each step to `send` (`false`: nobody takes them any more — stop). + /// Stops before the next piece once `progress.files.cancel` is set; what was handed over + /// stays handed over. + pub fn run(self, progress: &SyncProgress, mut send: impl FnMut(SyncStep) -> bool) { + use std::sync::atomic::Ordering::Relaxed; + let cancel = &progress.files.cancel; + let SyncWork { import, moved, missing, sidecars } = self; + let pieces = import.as_ref().map_or(0, |(_, f)| f.len()) + moved.len() + missing.len() + sidecars.len(); + progress.to_check.store(pieces, Relaxed); + progress.checked.store(0, Relaxed); + let step = |n: usize| { + progress.checked.fetch_add(n, Relaxed); + }; + if let Some((mut job, files)) = import { + for chunk in files.chunks(crate::import::batch_size()) { + if cancel.load(Relaxed) { + return; + } + let prepared = job.prepare_files(chunk.to_vec(), cancel); + step(chunk.len()); + // (the files are added in place: a batch nobody takes leaves nothing behind on disk) + if !send(SyncStep::Import(prepared)) { + return; + } + } + } + if cancel.load(Relaxed) { + return; + } + // still where the scan found it, and still gone from where the photo says + let checked = moved.len(); + let relink: Vec<(PhotoId, String)> = + moved.into_iter().filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists()).map(|m| (PhotoId(m.id), m.to)).collect(); + step(checked); + if !relink.is_empty() && !send(SyncStep::Relink(relink)) { + return; + } + if cancel.load(Relaxed) { + return; + } + let gone: Vec = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect(); + step(missing.len()); + if !gone.is_empty() && !send(SyncStep::Remove(gone)) { + return; + } + let mut read = Vec::with_capacity(sidecars.len()); + for (id, path, naming, target) in sidecars { + if cancel.load(Relaxed) { + break; + } + read.push((id, path.clone(), read_sidecar(&path, naming, target))); + step(1); + } + if !read.is_empty() { + send(SyncStep::Sidecars(read)); + } + } +} + +/// Read and parse photo file `path`'s XMP sidecar file. +fn read_sidecar(path: &str, naming: SidecarNaming, target: crate::crs::Target) -> std::result::Result { + let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?; + let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len(); + if len > MAX_SIDECAR_BYTES { + return Err(format!("{}: too large for an XMP sidecar", file.display())); + } + let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; + crate::sidecar::parse_sidecar(&text, target).map_err(|e| format!("{}: {e}", file.display())) +} + +impl SyncCommit { + /// Put a step into the catalog (no file is touched). A part that fails is reported in the + /// end, and the rest is still done. + pub fn apply(&mut self, s: &mut Session, step: SyncStep) { + if !Arc::ptr_eq(&self.library, &s.library_identity) { + self.report.failed.push((String::new(), "another library was opened: the rest of the synchronize was dropped".into())); + return; + } + let commits = s.commits(); + if self.after_last.is_some_and(|n| n != s.undo.len()) { + self.interleaved = true; + } + self.apply_step(s, step); + if s.commits() != commits { + self.own += usize::try_from(s.commits().wrapping_sub(commits)).unwrap_or(0); + self.after_last = Some(s.undo.len()); + } + } + + fn apply_step(&mut self, s: &mut Session, step: SyncStep) { + let report = &mut self.report; + match step { + SyncStep::Import(prepared) => match crate::import::commit_prepared(s, &self.opts, &self.now, prepared) { + Ok(r) => { + report.imported += r.imported.len(); + report.failed.extend(r.failed); + } + Err(e) => report.failed.push((String::new(), e.to_string())), + }, + SyncStep::Relink(moved) => { + let ops: Vec = + moved.iter().filter(|(id, _)| s.catalog.photo(*id).is_some()).map(|(id, to)| crate::cmd::missing::relink_op(*id, to)).collect(); + let n = ops.len(); + if n > 0 { + match s.commit("Relink Moved Photos", Op::Batch { ops }) { + Ok(()) => report.relinked += n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } + } + } + SyncStep::Remove(gone) => { + let ops: Vec = gone + .iter() + .filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library())) + .map(|id| Op::SetDeleted { id: *id, deleted: true }) + .collect(); + let n = ops.len(); + if n > 0 { + match s.commit("Remove Missing Photos", Op::Batch { ops }) { + Ok(()) => report.removed += n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } + } + } + SyncStep::Sidecars(read) => { + let mut ops = Vec::new(); + for (id, path, sc) in read { + match sc.map_err(EngineError::Other).and_then(|sc| s.sidecar_op(id, sc)) { + Ok(op) => ops.push(op), + Err(e) => report.failed.push((path, e.to_string())), + } + } + let n = ops.len(); + if n > 0 { + match s.commit("Read Metadata from File", Op::Batch { ops }) { + Ok(()) => report.read += n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } + } + } + } + } + + /// What was done, as one undo step ("Synchronize Folder"). + pub fn finish(mut self, s: &mut Session) -> SyncReport { + // a report line once, however many steps came after a library switch + self.report.failed.dedup(); + let ours = Arc::ptr_eq(&self.library, &s.library_identity) && !self.interleaved && self.after_last == Some(s.undo.len()); + let steps = if ours { self.own.min(s.undo.len()) } else { 0 }; + s.merge_undo(steps, "Synchronize Folder"); + if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) { + last.label = "Synchronize Folder".into(); + } + self.report + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d26f5a0f6..7b0f6cae7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -14,7 +14,7 @@ fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { fn changing_one_wb_control_resolves_as_shot_without_stale_tint() { use lightcraft_catalog::{Photo, PhotoId, Source}; use lightcraft_develop::{DevelopSettings, WbMode}; - for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF"] { + for format in ["ARW", "NEF", "NRW", "RAF", "CR2", "PEF", "ORF"] { let mut s = demo(); let id = PhotoId(100); let name = format!("synthetic.{format}"); @@ -113,9 +113,10 @@ fn albums_crud() { s.execute("library.selectAll", &json!({})).unwrap(); s.execute("album.addPhotos", &json!({"id": a})).unwrap(); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().photos.len(), 24); - assert!(s.execute("album.delete", &json!({"id": f})).is_err(), "non-empty folder"); - s.execute("album.delete", &json!({"id": a})).unwrap(); s.execute("album.delete", &json!({"id": f})).unwrap(); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(a)).is_none()); + assert!(s.catalog.album(lightcraft_catalog::AlbumId(f)).is_none()); + assert_eq!(s.catalog.photos().count(), 24, "album deletion keeps the photos"); } /// Given an album and a folder, when something is created inside each, then only the folder @@ -198,6 +199,60 @@ fn deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor"); } +#[test] +fn permanently_deleting_photos_selects_a_neighbour_in_recently_deleted() { + for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] { + for at_end in [false, true] { + let mut s = demo(); + s.execute("library.selectAll", &json!({})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap(); + s.execute("library.sort", ¶ms).unwrap(); + s.execute("library.filter", &json!({"rating": 2})).unwrap(); + let visible = s.visible_cloned(); + assert!(visible.len() > 5); + let at = if at_end { visible.len() - 2 } else { visible.len() / 2 }; + let deleted = &visible[at..at + 2]; + s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap(); + let snapshot = s.catalog.to_snapshot(); + let undo_len = s.undo.len(); + let r = s.execute("photo.deletePermanently", &json!({})).unwrap(); + assert_eq!(r["deleted"], 2); + let expected = if at_end { visible[at - 1] } else { visible[at + 2] }; + assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}"); + assert!(deleted.iter().all(|id| s.catalog.photo(*id).is_none())); + assert_eq!(s.undo.len(), undo_len + 1); + let removed = s.catalog.to_snapshot(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.to_snapshot(), snapshot); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.catalog.to_snapshot(), removed); + } + } + let mut s = demo(); + s.execute("library.selectAll", &json!({})).unwrap(); + s.execute("photo.deletePermanently", &json!({})).unwrap(); + assert!(s.visible().is_empty()); + assert_eq!(s.selection, Selection::default()); +} + +#[test] +fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { + let mut s = demo(); + let visible = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap(); + let before = s.selection.clone(); + s.execute("photo.deletePermanently", &json!({"ids": [visible[3]]})).unwrap(); + assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection"); + s.execute("photo.deletePermanently", &json!({"ids": [visible[9]]})).unwrap(); + assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor"); + let before = s.selection.clone(); + let snapshot = s.catalog.to_snapshot(); + assert!(s.execute("photo.deletePermanently", &json!({"ids": [999_999]})).is_err()); + assert_eq!(s.selection, before, "a refused deletion doesn't change selection"); + assert_eq!(s.catalog.to_snapshot(), snapshot); +} + #[test] fn masks_crop_and_render() { let mut s = demo(); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index faba3e3c1..b8636c018 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -370,3 +370,73 @@ fn exports_are_atomic_without_a_sync() { assert!(syncs_on_this_thread() > before, "the durable writer syncs"); let _ = std::fs::remove_dir_all(&dir); } + +/// The demo library's ocean sunset (a sun far above SDR white), selected and active. +fn sunset(s: &mut Session) -> lightcraft_catalog::PhotoId { + let id = s.catalog.photos().find(|p| p.meta.title == "Golden horizon").map(|p| p.id).unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + id +} + +#[test] +fn hdr_jpeg_export_writes_a_gain_map_and_sdr_edits_stay_plain() { + use lightcraft_codecs::gainmap; + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 320, "hdr": true})); + assert!(o.hdr_output()); + // no HDR edit: an ordinary JPEG even with HDR output asked for + let plain = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(!gainmap::is_gain_map_jpeg(&plain.bytes)); + + let r = s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap(); + assert_eq!(r["enabled"], true); + let e = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(gainmap::is_gain_map_jpeg(&e.bytes)); + let gm = gainmap::read_jpeg(&e.bytes).unwrap(); + assert!(gm.meta.alternate_headroom > 0.5 && gm.meta.alternate_headroom <= 3.05, "headroom {}", gm.meta.alternate_headroom); + // the base is the SDR rendition: the same pixels as the SDR export (the decoders agree) + let a = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap(); + let b = lightcraft_codecs::decode(&plain.bytes, Default::default()).unwrap(); + assert_eq!((a.width, a.height), (b.width, b.height)); + let diff = a.image.data.iter().zip(&b.image.data).map(|(p, q)| (p[1] - q[1]).abs()).fold(0.0f32, f32::max); + assert!(diff < 0.02, "base differs from the SDR export by {diff}"); + // HDR off again: the gain map goes away + s.execute("develop.hdr", &json!({"enabled": false})).unwrap(); + assert!(!gainmap::is_gain_map_jpeg(&export_photo(&mut s, id, &o, 1).unwrap().bytes)); +} + +#[test] +fn hdr_float_tiff_keeps_highlights_above_white() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let max_of = |s: &mut Session, hdr: bool| { + let o = ExportOptions::from_json(&json!({"longEdge": 240, "format": "tiff", "bitDepth": 32, "hdr": hdr})); + let e = export_photo(s, id, &o, 1).unwrap(); + let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap(); + d.image.data.iter().flat_map(|p| p.iter().copied()).fold(0.0f32, f32::max) + }; + let hdr = max_of(&mut s, true); + let sdr = max_of(&mut s, false); + assert!(sdr <= 1.0 + 1e-3, "SDR float TIFF stays in 0..1 ({sdr})"); + assert!(hdr > 1.2 && hdr <= 4.0 + 1e-3, "HDR float TIFF max {hdr}"); +} + +#[test] +fn hdr_avif_export_is_pq_rec2020() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 160, "format": "avif", "hdr": true, "quality": 70})); + assert!(o.hdr_output()); + let sdr = export_photo(&mut s, id, &o, 1).unwrap(); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let hdr = export_photo(&mut s, id, &o, 1).unwrap(); + // the `colr` nclx box: BT.2020 primaries (9), PQ (16), BT.2020 NCL (9), full range + let nclx = |b: &[u8]| b.windows(4).position(|w| w == b"nclx").map(|i| b[i + 4..i + 11].to_vec()); + let c = nclx(&hdr.bytes).expect("colr nclx box"); + assert_eq!(c, vec![0, 9, 0, 16, 0, 9, 0x80]); + assert!(hdr.bytes.windows(4).any(|w| w == b"clli"), "content light level box"); + // a photo without an HDR edit exports as an ordinary (sRGB) AVIF + assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16)); +} diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index a94921373..9f51ab408 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -8,6 +8,11 @@ //! * Removing a folder from the library moves its photos to Recently Deleted (one undo step) and //! leaves every file where it is. //! * Renaming or moving a folder on disk keeps the chosen folder chosen. +//! * Given a folder of the library, an agent gives it a colour label with `folder.label`; the +//! folder list shows it, undo takes it back, and only folders the library holds photos in can +//! be labelled. +//! * A labelled folder keeps its label when it is renamed or moved on disk, and so do the folders +//! inside it; undo puts the labels back where they were. use lightcraft_catalog::{Op, Photo, Source}; use serde_json::json; @@ -463,3 +468,99 @@ fn a_blank_folder_filter_is_no_filter() { s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap(); assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state"); } + +/// The label `library.folders` reports for the row at `path`. +fn listed_label(s: &mut Session, path: &str) -> Option { + fn find(rows: &serde_json::Value, key: &str) -> Option { + rows.as_array()?.iter().find_map(|r| { + if r["path"].as_str().map(lightcraft_catalog::query::folder_key).as_deref() == Some(key) { + Some(r.clone()) + } else { + find(&r["children"], key) + } + }) + } + let rows = s.execute("library.folders", &json!({})).unwrap(); + let row = find(&rows, &lightcraft_catalog::query::folder_key(path)).unwrap_or_else(|| panic!("no row for {path}: {rows}")); + row["label"].as_str().map(str::to_string) +} + +#[test] +fn a_labelled_folder_shows_its_label_in_the_folder_list_until_undone() { + let mut s = session(); + s.execute("folder.label", &json!({"path": "/pics/trip", "label": "green"})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green")); + assert_eq!(listed_label(&mut s, "/pics/home"), None, "its neighbour has none"); + s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip"), None); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip").as_deref(), Some("green"), "undo puts the label back"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, "/pics/trip"), None); +} + +#[test] +fn only_a_folder_of_the_library_takes_a_known_label() { + let mut s = session(); + let before = s.catalog.to_snapshot(); + for (p, why) in [ + (json!({"path": "/pics/trip", "label": "mauve"}), "unknown label"), + (json!({"path": "/pics/trip"}), "missing label"), + (json!({"label": "red"}), "missing path"), + (json!({"path": " ", "label": "red"}), "blank path"), + (json!({"path": "/elsewhere", "label": "red"}), "no photo was imported from it"), + (json!({"path": "pics/trip", "label": "red"}), "a relative path"), + ] { + assert!(s.execute("folder.label", &p).is_err(), "{why}"); + } + assert_eq!(s.catalog.to_snapshot(), before, "nothing changed"); +} + +#[test] +fn labels_follow_a_renamed_folder_and_undo_puts_them_back() { + let dir = Scratch::new("label-rename"); + std::fs::create_dir_all(dir.0.join("trip/day1")).unwrap(); + let (trip, day1) = (dir.path("trip"), dir.path("trip/day1")); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + add(&mut s, &format!("{day1}/b.jpg")); + s.execute("folder.label", &json!({"path": trip, "label": "red"})).unwrap(); + s.execute("folder.label", &json!({"path": day1, "label": "blue"})).unwrap(); + s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap(); + assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red")); + assert_eq!(listed_label(&mut s, &dir.path("holiday/day1")).as_deref(), Some("blue"), "the folder inside goes along"); + assert_eq!(s.catalog.folder_record(&trip), None, "nothing stays behind"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("red")); + assert_eq!(listed_label(&mut s, &day1).as_deref(), Some("blue")); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, &dir.path("holiday")).as_deref(), Some("red"), "redo carries them again"); +} + +#[test] +fn labels_follow_a_moved_folder() { + let dir = Scratch::new("label-move"); + std::fs::create_dir_all(dir.0.join("trip")).unwrap(); + std::fs::create_dir_all(dir.0.join("archive")).unwrap(); + let trip = dir.path("trip"); + let mut s = Session::new(); + add(&mut s, &format!("{trip}/a.jpg")); + s.execute("folder.label", &json!({"path": trip, "label": "yellow"})).unwrap(); + s.execute("folder.move", &json!({"path": trip, "into": dir.path("archive")})).unwrap(); + assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, &trip).as_deref(), Some("yellow"), "undo moves it back with the folder"); + assert_eq!(s.catalog.folder_record(&dir.path("archive/trip")), None); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(listed_label(&mut s, &dir.path("archive/trip")).as_deref(), Some("yellow")); +} + +#[test] +fn a_label_comes_off_a_folder_whose_photos_are_gone() { + let mut s = session(); + s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).unwrap(); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + s.execute("folder.label", &json!({"path": "/pics/trip", "label": "none"})).unwrap(); + assert_eq!(s.catalog.folder_record("/pics/trip"), None); + assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos"); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 4a4cad539..784200310 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -974,8 +974,27 @@ fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() { fn deleting_several_photos_permanently_leaves_nothing_dangling() { let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs"); s.execute("library.select", &json!({"ids": ids})).unwrap(); + let files = [src.join("a.png"), src.join("b.png")].map(|path| { + let bytes = std::fs::read(&path).unwrap(); + (path, bytes) + }); + let snapshot = s.catalog.to_snapshot(); + s.drain_log(); s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap(); assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty()); + assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none())); + let removed = s.catalog.to_snapshot(); + let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect(); + let mut reopened = lightcraft_catalog::Catalog::from_snapshot(&snapshot).unwrap(); + reopened.replay(&log).unwrap(); + assert_eq!(reopened.to_snapshot(), removed); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.to_snapshot(), snapshot, "one undo restores photos, album and stack"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.catalog.to_snapshot(), removed); + for (path, bytes) in files { + assert_eq!(std::fs::read(path).unwrap(), bytes, "permanent deletion only removes catalog records"); + } let _ = std::fs::remove_dir_all(&src); } @@ -1105,3 +1124,54 @@ fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() { assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30))); let _ = std::fs::remove_dir_all(&src); } + +#[test] +fn a_file_gone_since_the_import_review_is_not_imported() { + let src = temp_dir("gone-since-review"); + write_png(&src.join("a.png"), 1); + write_png(&src.join("b.png"), 2); + let mut s = Session::new().with_fs(); + s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + std::fs::remove_file(src.join("b.png")).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 1), "{r}"); + assert_eq!(s.catalog.len(), 1, "no photo for a file that isn't there"); + let _ = std::fs::remove_dir_all(&src); +} + +/// A HEIC laid out like an iPhone's (a grid of HEVC pictures, a Display P3 profile, a quarter turn +/// in the container, a thumbnail) imports as a normal photo that develops and exports upright like +/// a JPEG. In a build without the codecs' `heif` feature it is reported as failed, saying why. +#[test] +fn heic_imports_as_a_normal_photo_or_says_why_not() { + use lightcraft_heif::testdata::{Prop, Spec, build}; + let src = temp_dir("heic"); + let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150]; + let p3 = lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3); + let bytes = build(&Spec { props: vec![Prop::Icc(p3), Prop::Irot(1)], thumbnail: Some(&photo), ..Spec::new(96, 64, &photo) }); + std::fs::write(src.join("IMG_0001.HEIC"), &bytes).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + if !lightcraft_codecs::Format::Heif.can_decode() { + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (0, 1), "{r}"); + assert!(r["failed"].to_string().contains("isn't included in this build"), "{r}"); + let _ = std::fs::remove_dir_all(&src); + return; + } + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}"); + let p = s.catalog.photos().next().unwrap(); + let id = p.id; + // Upright (the container's turn applied) and not preview-only. + assert_eq!((p.kind, p.preview_only.clone(), p.width, p.height), (lightcraft_catalog::MediaKind::Image, None, 64, 96)); + s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let plain = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap(); + assert_eq!((plain.width, plain.height), (64, 96)); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.0})).unwrap(); + let brighter = crate::export::export_photo(&mut s, id, &crate::export::ExportOptions::from_json(&json!({"format": "png"})), 1).unwrap(); + let mean = |png: &[u8]| { + let d = lightcraft_codecs::decode(png, Default::default()).unwrap(); + d.image.data.iter().map(|p| p[1]).sum::() / d.image.data.len() as f32 + }; + assert!(mean(&brighter.bytes) > mean(&plain.bytes) * 1.3, "exposure +1 brightens the HEIC"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs new file mode 100644 index 000000000..b98b45de8 --- /dev/null +++ b/crates/engine/src/tests_keyword_list.rs @@ -0,0 +1,341 @@ +//! The keyword list's commands (Lightroom Classic's Keyword List): create, edit, move and purge +//! keywords, their attributes, and the default parent for new keywords. + +use serde_json::json; + +use crate::Session; + +/// The keyword filter follows a renamed keyword whatever the case it was typed in, also where a +/// letter's case changes its length (ẞ / ß): it used to be cut by bytes, which could land inside +/// a letter and panic. +#[test] +fn the_filter_follows_a_rename_whatever_the_case() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["ßßa|é"]})).unwrap(); + s.execute("library.filter", &json!({"keyword": "ßßa|é"})).unwrap(); + s.execute("keyword.rename", &json!({"from": "ẞẞA", "to": "Road"})).unwrap(); + assert_eq!(s.filter.keyword.as_deref(), Some("Road|é")); + s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap(); + assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é")); +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-keyword-list-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn keywords_of(s: &Session, id: u64) -> Vec { + s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone() +} + +/// Create Keyword: a name inside a parent, with attributes, given to the selected photos at once, +/// in one undo step; `keyword.info` reads it back. A keyword that exists isn't created again. +#[test] +fn creating_a_keyword_with_attributes_and_the_selection() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("library.select", &json!({"ids": [id]})).unwrap(); + let undo = s.undo.len(); + let r = s + .execute("keyword.create", &json!({"name": "Weddings", "parent": "Events", "synonyms": ["marriage"], "person": false, "addToSelected": true})) + .unwrap(); + assert_eq!(r["keyword"], "Events|Weddings", "{r}"); + assert_eq!(s.undo.len(), undo + 1, "one undo step"); + assert!(keywords_of(&s, id).contains(&"Events|Weddings".to_string())); + let info = s.execute("keyword.info", &json!({"keyword": "events|weddings"})).unwrap(); + assert_eq!(info["path"], "Events|Weddings"); + assert_eq!(info["synonyms"], json!(["marriage"])); + assert_eq!((info["includeOnExport"].as_bool(), info["listed"].as_bool(), info["count"].as_u64()), (Some(true), Some(true), Some(1))); + let err = s.execute("keyword.create", &json!({"name": "weddings", "parent": "events"})).unwrap_err().to_string(); + assert!(err.contains("already"), "{err}"); + assert!(s.execute("keyword.info", &json!({"keyword": "lisbon"})).is_err(), "no such keyword"); +} + +/// "Put new keywords inside this keyword": new keywords go inside the default parent unless the +/// command says otherwise (`parent: null` = the top level). It belongs to its keyword in the +/// library: it follows a rename, goes with a delete, and undo and redo bring it back with them. +#[test] +fn new_keywords_go_inside_the_default_parent() { + let dir = temp_dir("default-parent"); + let mut s = Session::new(); + s.open_library(&dir, true).unwrap(); + s.execute("keyword.create", &json!({"name": "Events"})).unwrap(); + s.execute("keyword.setDefaultParent", &json!({"keyword": "events"})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events")); + assert_eq!(s.execute("keyword.create", &json!({"name": "Birthdays"})).unwrap()["keyword"], "Events|Birthdays"); + assert_eq!(s.execute("keyword.create", &json!({"name": "Travel", "parent": null})).unwrap()["keyword"], "Travel"); + s.execute("keyword.rename", &json!({"from": "Events", "to": "Occasions"})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "follows the rename"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events"), "undo takes it back"); + s.execute("edit.redo", &json!({})).unwrap(); + drop(s); + let mut s = Session::new(); + s.open_library(&dir, false).unwrap(); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "kept with the library"); + s.execute("keyword.delete", &json!({"keyword": "occasions"})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent(), None, "gone with its keyword"); + assert_eq!(s.execute("keyword.create", &json!({"name": "Graduations"})).unwrap()["keyword"], "Graduations", "at the top level"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "undoing the delete brings it back"); + s.execute("keyword.setDefaultParent", &json!({"keyword": null})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent(), None); + assert!(s.execute("keyword.setDefaultParent", &json!({"keyword": "lisbon"})).is_err(), "no such keyword"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Edit Keyword: a new name and attributes in one step; attributes not given keep their values. +#[test] +fn editing_a_keyword_changes_what_it_is_given() { + let mut s = Session::with_demo(); + s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap(); + let undo = s.undo.len(); + s.execute("keyword.edit", &json!({"keyword": "weddings", "name": "Marriages", "includeOnExport": false})).unwrap(); + assert_eq!(s.undo.len(), undo + 1); + let info = s.execute("keyword.info", &json!({"keyword": "Marriages"})).unwrap(); + assert_eq!((info["includeOnExport"].as_bool(), info["synonyms"].clone()), (Some(false), json!(["marriage"]))); + assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err()); +} + +/// Move Keyword: inside another or to the top level. Onto a name that is taken it is refused with +/// a message saying it would merge, unless `merge: true`. +#[test] +fn moving_a_keyword_merges_only_when_asked() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(2).map(|p| p.0).collect(); + s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["Rome"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["Europe|Rome"]})).unwrap(); + let err = s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe"})).unwrap_err().to_string(); + assert!(err.contains("merge"), "{err}"); + s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe", "merge": true})).unwrap(); + assert_eq!(keywords_of(&s, ids[0]), ["Europe|Rome"]); + s.execute("keyword.move", &json!({"keyword": "europe|rome", "parent": null})).unwrap(); + assert_eq!((keywords_of(&s, ids[0]), keywords_of(&s, ids[1])), (vec!["Rome".to_string()], vec!["Rome".to_string()])); + assert!(s.execute("keyword.move", &json!({"keyword": "rome"})).is_err(), "`parent` must be said, null for the top level"); +} + +/// Purge Unused Keywords takes off the list the keywords no photo has, in one undo step. +#[test] +fn purging_unused_keywords() { + let mut s = Session::with_demo(); + s.execute("keyword.create", &json!({"name": "Weddings"})).unwrap(); + let r = s.execute("keyword.purgeUnused", &json!({})).unwrap(); + assert_eq!(r["purged"], 1, "{r}"); + assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_ok()); +} + +/// Moving a keyword into a parent that doesn't exist yet makes that parent, and the keyword filter +/// follows the keyword there (the grid kept filtering by the old path, and showed nothing). +#[test] +fn the_filter_follows_a_keyword_moved_into_a_new_parent() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Rome"]})).unwrap(); + s.execute("library.filter", &json!({"keyword": "Rome"})).unwrap(); + s.execute("keyword.move", &json!({"keyword": "rome", "parent": "Italy"})).unwrap(); + assert_eq!(keywords_of(&s, id), ["Italy|Rome"]); + assert_eq!(s.filter.keyword.as_deref(), Some("Italy|Rome")); +} + +/// Keywords given to photos are stored cleaned (" beach " is "beach", "Travel | Italy" is +/// "Travel|Italy"), so the list's actions find them; one a photo has already, whatever the case of +/// any letter, isn't added twice. +#[test] +fn keywords_given_to_photos_are_stored_cleaned() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": [" beach ", "Travel | Italy", "Ärzte"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": [" sea ", "ÄRZTE", " "]})).unwrap(); + assert_eq!(keywords_of(&s, id), ["beach", "Travel|Italy", "Ärzte", "sea"]); + s.execute("photo.setMeta", &json!({"ids": [id], "removeKeywords": ["travel | italy"]})).unwrap(); + assert_eq!(keywords_of(&s, id), ["beach", "Ärzte", "sea"]); + assert!(s.execute("keyword.info", &json!({"keyword": "beach"})).is_ok()); +} + +/// Rename, merge and delete say how many photos they changed, as before the keyword list: the +/// list's own changes aren't photos. +#[test] +fn keyword_actions_count_the_photos_they_change() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap(); + assert_eq!(s.execute("keyword.rename", &json!({"from": "Weddings", "to": "Marriages"})).unwrap()["changed"], 0); + s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["Marriages"]})).unwrap(); + assert_eq!(s.execute("keyword.rename", &json!({"from": "Marriages", "to": "Weddings"})).unwrap()["changed"], 1); + assert_eq!(s.execute("keyword.delete", &json!({"keyword": "weddings"})).unwrap()["changed"], 1); +} + +/// ⌥1–⌥9 take a set's keyword off the selected photos when they all have it, whatever the case of +/// any of its letters (the set has "ärzte", the photos "ÄRZTE" and "ärzte"). +#[test] +fn a_set_keyword_toggles_whatever_its_case() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(2).map(|p| p.0).collect(); + s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["ÄRZTE"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["ärzte"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Clinic", "keywords": ["ärzte"]})).unwrap(); + s.execute("keyword.toggleFromSet", &json!({"index": 1, "ids": ids})).unwrap(); + assert!(ids.iter().all(|id| keywords_of(&s, *id).is_empty()), "taken off both"); +} + +fn set_named(s: &mut Session, name: &str) -> serde_json::Value { + let sets = s.execute("keyword.sets", &json!({})).unwrap(); + sets["sets"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned().unwrap_or(serde_json::Value::Null) +} + +/// Edit Keyword Set: a set's nine slots keep their places, so each keyword stays on its ⌥ key; an +/// empty slot is allowed and does nothing. +#[test] +fn a_keyword_sets_slots_keep_their_places() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": [" ceremony ", "", "reception", ""]})).unwrap(); + assert_eq!(set_named(&mut s, "Weddings")["keywords"], json!(["ceremony", "", "reception"]), "trailing empty slots go"); + let before = keywords_of(&s, id); + let r = s.execute("keyword.toggleFromSet", &json!({"index": 2, "ids": [id]})).unwrap(); + assert_eq!((r["changed"].as_u64(), keywords_of(&s, id)), (Some(0), before), "⌥2 is empty: nothing"); + s.execute("keyword.toggleFromSet", &json!({"index": 3, "ids": [id]})).unwrap(); + assert!(keywords_of(&s, id).contains(&"reception".to_string()), "⌥3 is reception"); +} + +/// A set holds each keyword once, whatever its case: a second one leaves its slot empty. +#[test] +fn a_keyword_set_holds_each_keyword_once() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["Lisbon", "harbour", "LISBON"]})).unwrap(); + assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["Lisbon", "harbour"])); +} + +/// Renaming a set keeps its place among the sets and keeps it current; a name another set has is +/// refused. +#[test] +fn renaming_a_keyword_set_keeps_its_place() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "Weddings"})).unwrap(); + let r = s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "weddings", "keywords": ["ceremony", "rings"]})).unwrap(); + let names: Vec = r["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(names, ["Recent Keywords", "Ceremonies", "Travel"]); + assert_eq!(r["current"], "Ceremonies"); + assert_eq!(r["keywords"], json!(["ceremony", "rings"])); + let err = s.execute("keyword.saveSet", &json!({"name": "travel", "replace": "Ceremonies", "keywords": []})).unwrap_err().to_string(); + assert!(err.contains("already"), "{err}"); + assert!(s.execute("keyword.saveSet", &json!({"name": "Rings", "replace": "Lisbon", "keywords": []})).is_err(), "no such set"); +} + +/// Renaming a set without giving its keywords keeps its keywords (not the current set's). +#[test] +fn renaming_a_set_alone_keeps_its_keywords() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap(); + s.execute("keyword.saveSet", &json!({"name": "Ceremonies", "replace": "Weddings"})).unwrap(); + assert_eq!(set_named(&mut s, "Ceremonies")["keywords"], json!(["ceremony"])); +} + +/// A new set (`new: true`, what Edit Set… on Recent Keywords saves) never takes another set's +/// name: saving over it silently is refused. +#[test] +fn a_new_set_doesnt_take_a_sets_name() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour", "lisbon"]})).unwrap(); + let err = s.execute("keyword.saveSet", &json!({"name": "travel", "new": true, "keywords": ["x"]})).unwrap_err().to_string(); + assert!(err.contains("already"), "{err}"); + assert_eq!(set_named(&mut s, "Travel")["keywords"], json!(["harbour", "lisbon"]), "left as it was"); +} + +/// Set names match whatever the case of any of their letters, for every set command alike. +#[test] +fn set_names_match_whatever_their_case() { + let mut s = Session::with_demo(); + s.execute("keyword.saveSet", &json!({"name": "ÉTÉ", "keywords": ["sunset"]})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap(); + s.execute("keyword.useSet", &json!({"name": "été"})).unwrap(); + assert_eq!(s.execute("keyword.sets", &json!({})).unwrap()["current"], "ÉTÉ"); + s.execute("keyword.deleteSet", &json!({"name": "été"})).unwrap(); + assert!(set_named(&mut s, "ÉTÉ").is_null(), "deleted"); +} + +/// Export Keywords writes the keyword list file, and names the keywords Capture One's importer +/// refuses (it allows none of ; , < > in a list); without a path it gives the text. +#[test] +fn exporting_keywords_writes_the_list() { + let dir = temp_dir("export-keywords"); + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Places|Lisbon", "fish, chips"]})).unwrap(); + let path = dir.join("keywords.txt"); + let r = s.execute("keyword.export", &json!({"path": path.to_string_lossy()})).unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("Places\n\tLisbon\n"), "{text}"); + assert!(r["keywords"].as_u64().unwrap() >= 3, "{r}"); + assert_eq!(r["captureOneRefuses"], json!(["fish, chips"]), "{r}"); + assert_eq!(r["unwritable"], json!([]), "{r}"); + let r = s.execute("keyword.export", &json!({})).unwrap(); + assert_eq!(r["text"].as_str(), Some(text.as_str())); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A keyword the format can't hold is left out of the file and named, so the file reads back. +#[test] +fn exporting_keywords_names_what_the_list_cant_hold() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Travel|[draft]", "Travel|Lisbon"]})).unwrap(); + let before = s.execute("keyword.export", &json!({})).unwrap()["keywords"].as_u64().unwrap(); + // a name starting with a brace is written, counted and checked for Capture One like any other + s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["{draft, notes"]})).unwrap(); + let r = s.execute("keyword.export", &json!({})).unwrap(); + assert_eq!(r["keywords"].as_u64(), Some(before + 1), "{r}"); + assert_eq!(r["captureOneRefuses"], json!(["{draft, notes"]), "{r}"); + assert_eq!(r["unwritable"], json!(["Travel|[draft]"]), "{r}"); + let text = r["text"].as_str().unwrap(); + assert!(text.contains("Travel\n\tLisbon\n") && !text.contains("[draft]"), "{text}"); + s.execute("keyword.import", &json!({"text": text})).unwrap(); +} + +/// A list saved as UTF-16 (as Windows' Notepad can) says to save it as UTF-8, and text too big to +/// be a keyword list is refused, as a file that big is. +#[test] +fn importing_keywords_refuses_what_it_cant_read() { + let dir = temp_dir("import-keywords-refused"); + let mut s = Session::with_demo(); + let utf16: Vec = [0xff, 0xfe].into_iter().chain("Travel\r\n".encode_utf16().flat_map(u16::to_le_bytes)).collect(); + std::fs::write(dir.join("travel.txt"), utf16).unwrap(); + let err = s.execute("keyword.import", &json!({"path": dir.join("travel.txt").to_string_lossy()})).unwrap_err().to_string(); + assert!(err.contains("UTF-16") && err.contains("UTF-8"), "{err}"); + let big = "Travel\n".repeat((17 << 20) / 7); + let err = s.execute("keyword.import", &json!({"text": big})).unwrap_err().to_string(); + assert!(err.contains("16 MB"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Import Keywords reads a keyword list file (or text) as one undo step and says what it added; +/// a file it can't read says which line, and nothing is taken. +#[test] +fn importing_keywords_reads_a_list() { + let dir = temp_dir("import-keywords"); + let mut s = Session::with_demo(); + let path = dir.join("vocabulary.utf8"); + std::fs::write(&path, "[Places]\r\n\tPortugal\r\n\t\tLisbon\r\n").unwrap(); + let undo = s.undo.len(); + let r = s.execute("keyword.import", &json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((r["added"].as_u64(), r["updated"].as_u64()), (Some(3), Some(0)), "{r}"); + assert_eq!(s.undo.len(), undo + 1); + assert!(s.catalog.has_keyword("Places|Portugal|Lisbon")); + let r = s.execute("keyword.import", &json!({"text": "Events\n\t{celebrations}\n"})).unwrap(); + assert_eq!(r["added"], 1); + let err = s.execute("keyword.import", &json!({"text": "Events\n\t\tWeddings\n"})).unwrap_err().to_string(); + assert!(err.contains("line 2"), "{err}"); + std::fs::write(dir.join("bad.txt"), [0xff, 0xfe, 0x00, 0x41]).unwrap(); + assert!(s.execute("keyword.import", &json!({"path": dir.join("bad.txt").to_string_lossy()})).is_err(), "not UTF-8: refused"); + assert!(s.execute("keyword.import", &json!({})).is_err(), "a path or text"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs index f9e0f4921..b35167c1b 100644 --- a/crates/engine/src/tests_libops.rs +++ b/crates/engine/src/tests_libops.rs @@ -355,9 +355,9 @@ fn label_sets_and_xmp_label_names() { p.label = Some(ColorLabel::Green); let x = crate::sidecar::sidecar_packet(&p, &s.catalog); assert!(x.contains("Approved"), "{x}"); - let sc = crate::sidecar::parse_sidecar(&x, false).unwrap().resolve_label(&s.catalog); + let sc = crate::sidecar::parse_sidecar(&x, crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog); assert_eq!(sc.label, Some(Some(ColorLabel::Green))); - let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), false).unwrap().resolve_label(&s.catalog); + let plain = crate::sidecar::parse_sidecar(&x.replace("Approved", "Blue"), crate::crs::Target::Rendered).unwrap().resolve_label(&s.catalog); assert_eq!(plain.label, Some(Some(ColorLabel::Blue)), "colour names still work"); s.execute("label.deleteSet", &json!({"name": "Studio"})).unwrap(); assert!(s.execute("label.deleteSet", &json!({"name": "Review"})).is_err(), "built-ins stay"); @@ -551,3 +551,41 @@ fn choosing_random_starts_a_fresh_shuffle() { assert_eq!(s.sort.seed, 42, "an explicit seed is honoured"); assert!(s.sort.seed < 1 << 53); } + +fn capture_time_raw_metadata_session(raw: Option<&str>) -> Session { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut s = Session::new(); + let id = PhotoId(1); + let mut p = Photo::new(id, Source::Demo { scene: 0 }, "one.jpg", "JPEG", 4, 3, "2026-04-01T00:00:00"); + p.captured = raw.map(str::to_string); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.selection = crate::Selection::single(id); + s +} + +#[test] +fn capture_time_invalid_calendar_anchor_keeps_other_targets_and_redo() { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + for raw in ["2026-02-30T12:00:00", "2026x03x01T12:00:00"] { + let mut s = capture_time_raw_metadata_session(Some(raw)); + let peer = PhotoId(2); + let mut p = Photo::new(peer, Source::Demo { scene: 0 }, "peer.jpg", "JPEG", 4, 3, "2026-10-09T00:00:00"); + p.captured = Some("2026-03-01T12:00:00.25+02:00".into()); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.commit("Earlier rating", Op::SetRating { id: peer, rating: 4 }).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.selection.ids = vec![PhotoId(1), peer]; + s.selection.active = Some(PhotoId(1)); + let before = s.catalog.clone(); + let selection = s.selection.clone(); + let undo: Vec<_> = s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect(); + let redo: Vec<_> = s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect(); + let log = s.pending_log.clone(); + assert!(s.execute("photo.setCaptureTime", &json!({"time": "2026-03-03T12:00:00"})).is_err(), "{raw}"); + assert_eq!(s.catalog, before); + assert_eq!(s.selection, selection); + assert_eq!(s.undo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::>(), undo); + assert_eq!(s.redo.iter().map(|e| (e.label.clone(), e.op.clone())).collect::>(), redo); + assert_eq!(s.pending_log, log); + } +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index f7b89225e..8a2599e5c 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -252,11 +252,43 @@ fn smart_album_rule_sets() { s.clock = Box::new(|| "2026-10-02T12:00:00".to_string()); let fields = s.execute("album.ruleFields", &serde_json::json!({})).unwrap(); assert!(fields.as_array().unwrap().iter().any(|f| f["field"] == "keywords" && f["ops"].as_array().unwrap().len() > 3)); + // each field names its field-menu group (null at the top level) + let group = |id: &str| fields.as_array().unwrap().iter().find(|f| f["field"] == id).map(|f| f["group"].clone()); + assert_eq!(group("filePath"), Some(serde_json::json!("File"))); + assert_eq!(group("rating"), Some(serde_json::Value::Null)); + // choices stay the ids rules store; choiceLabels maps them to what people read + let flag = fields.as_array().unwrap().iter().find(|f| f["field"] == "copyrightStatus").unwrap(); + assert_eq!(flag["choices"], serde_json::json!(["copyrighted", "publicDomain", "unknown"])); + assert_eq!(flag["choiceLabels"]["publicDomain"], "Public Domain"); + let album = fields.as_array().unwrap().iter().find(|f| f["field"] == "album").unwrap(); + assert_eq!((album["kind"].as_str(), album["ops"].as_array().map(Vec::len)), (Some("album"), Some(2)), "an album id, is / isn't"); let bad = s.execute( "album.createSmart", &serde_json::json!({"name": "Bad", "rules": {"ruleSet": {"rules": [{"field": "rating", "op": "contains", "value": 1}]}}}), ); assert!(bad.is_err(), "operators are checked"); + // yes/no values: "false" means no, anything unreadable is an error rather than a silent yes + let maybe = s.execute( + "album.createSmart", + &serde_json::json!({"name": "Maybe", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "maybe"}]}}}), + ); + assert!(maybe.is_err_and(|e| e.to_string().contains("yes or no")), "unreadable yes/no value"); + // the library filter checks a rule set the same way, instead of quietly showing nothing + for rules in [serde_json::json!([{"field": "edited", "op": "is", "value": "maybe"}]), serde_json::json!([{"field": "nope", "op": "is"}])] { + let r = s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": rules}})); + assert!(r.is_err(), "{rules}"); + } + assert!(s.filter.rule_set.is_none(), "a refused filter isn't applied"); + s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "no"}]}})).unwrap(); + s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap(); + let r = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Unedited", "rules": {"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "false"}]}}}), + ) + .unwrap(); + let unedited = s.catalog.photos().filter(|p| !p.deleted && !p.is_edited()).count(); + assert_eq!(r["count"].as_u64(), Some(unedited as u64)); let r = s .execute( "album.createSmart", @@ -440,3 +472,206 @@ fn a_library_open_elsewhere_is_refused() { assert_eq!(other.catalog.to_snapshot(), expect); let _ = std::fs::remove_dir_all(&dir); } + +/// A capture-time shift from an agent can't overflow or write a date no one can read back: a +/// shift of more than 10,000 years is refused and nothing changes; an ordinary one still works. +#[test] +fn capture_time_shift_is_bounded() { + let mut s = crate::Session::with_demo(); + let id = s.catalog.photos().next().unwrap().id; + let before = s.catalog.photo(id).unwrap().captured.clone(); + for p in [ + serde_json::json!({"ids": [id.0], "shift": 1e30}), + serde_json::json!({"ids": [id.0], "hours": -1e15}), + serde_json::json!({"ids": [id.0], "shift": 9.3e18}), + ] { + let r = s.execute("photo.setCaptureTime", &p); + // a real refusal, not the panic guard catching an overflow ("failed unexpectedly") + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("10,000 years")), "{p}: {r:?}"); + assert_eq!(s.catalog.photo(id).unwrap().captured, before, "{p} changed nothing"); + } + s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 1})).unwrap(); + assert_ne!(s.catalog.photo(id).unwrap().captured, before); + // a shift that would take a photo past year 9999 says so, rather than skipping it quietly + s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "time": "9999-12-31T12:00:00"})).unwrap(); + let r = s.execute("photo.setCaptureTime", &serde_json::json!({"ids": [id.0], "hours": 24})); + assert!(r.as_ref().is_err_and(|e| e.to_string().contains("9999")), "{r:?}"); + assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("9999-12-31T12:00:00"), "unchanged"); +} + +/// Values are checked too: an agent's rule set with a date that isn't one, or a rating of 9, is +/// refused with the rule's position, by album.createSmart and library.filter alike. +#[test] +fn smart_rule_values_are_checked_by_commands() { + let mut s = crate::Session::with_demo(); + let r = s.execute( + "album.createSmart", + &serde_json::json!({"name": "Odd", "rules": {"ruleSet": {"rules": [ + {"field": "rating", "op": "gte", "value": 3}, + {"field": "captureDate", "op": "is", "value": "banana"} + ]}}}), + ); + let e = r.expect_err("a date that isn't one").to_string(); + assert!(e.contains("rule 2:") && e.contains("needs a date"), "{e}"); + assert!(s.catalog.albums().all(|a| a.name != "Odd")); + let e = s + .execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}})) + .expect_err("rating 9") + .to_string(); + assert!(e.contains("rule 1: no rating 0–5 is ≥ 9"), "{e}"); +} + +/// A smart album saved with an old album operator (≥, from when Album was a number field) can +/// still be edited: its rule is read as the "isn't" it always meant. +#[test] +fn old_album_operator_rules_stay_editable() { + use lightcraft_catalog::{Album, AlbumId, Op}; + let mut s = crate::Session::with_demo(); + let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules: lightcraft_catalog::Filter = + serde_json::from_value(serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "gte", "value": trip}]}})).unwrap(); + let id = s.catalog.alloc_album_id(); + s.catalog.apply(Op::AddAlbum { album: Album { smart: Some(Box::new(rules)), ..Album::new(id, "Old") } }).unwrap(); + s.execute("album.setRules", &serde_json::json!({"id": id.0, "rules": {"rating": 2}})).unwrap(); + let saved = serde_json::to_string(&s.catalog.album(AlbumId(id.0)).unwrap().smart).unwrap(); + assert!(saved.contains(r#""op":"isNot""#), "{saved}"); + s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "lte", "value": trip}]}})).unwrap(); +} + +/// Renaming and changing the rules is one step: both or neither, undone together. +#[test] +fn set_rules_with_a_name_is_one_step() { + let mut s = crate::Session::with_demo(); + let id = s.execute("album.createSmart", &serde_json::json!({"name": "Old", "rules": {"rating": 3}})).unwrap()["id"].as_u64().unwrap(); + let album = |s: &crate::Session| s.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().clone(); + let good = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 4}]}}); + let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "captureDate", "op": "is", "value": "banana"}]}}); + // refused rules: the name stays too + assert!(s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": bad})).is_err()); + assert_eq!(album(&s).name, "Old"); + let undo = s.undo.len(); + s.execute("album.setRules", &serde_json::json!({"id": id, "name": "New", "replace": true, "rules": good})).unwrap(); + assert_eq!((album(&s).name.as_str(), s.undo.len()), ("New", undo + 1), "one undo step"); + assert!(album(&s).smart.unwrap().rule_set.is_some()); + s.execute("edit.undo", &serde_json::json!({})).unwrap(); + assert_eq!(album(&s).name, "Old"); + assert!(album(&s).smart.unwrap().rule_set.is_none(), "undone together"); + // a blank name keeps the album's + s.execute("album.setRules", &serde_json::json!({"id": id, "name": " ", "rules": {"rating": 5}})).unwrap(); + assert_eq!(album(&s).name, "Old"); +} + +/// albums.list says which smart albums have rules that no longer check, and why. +#[test] +fn album_list_reports_problems() { + let mut s = crate::Session::with_demo(); + let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "is", "value": trip}]}}); + let id = s.execute("album.createSmart", &serde_json::json!({"name": "In Trip", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + let find = |s: &mut crate::Session| { + let list = s.execute("albums.list", &serde_json::json!({})).unwrap(); + list.as_array().unwrap().iter().find(|a| a["id"] == id).cloned().unwrap() + }; + assert_eq!(find(&mut s)["problems"], serde_json::json!([])); + s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap(); + assert_eq!(find(&mut s)["problems"], serde_json::json!([format!("rule 1: no album {trip}")])); +} + +/// "Travel" = keywords contain travel, but not in "Excluded Photos" (red or rejected): the counts +/// follow, and Excluded Photos can't then test Travel back (a loop). +#[test] +fn smart_album_excluding_a_smart_album() { + let mut s = crate::Session::with_demo(); + let excluded = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Excluded Photos", "rules": {"ruleSet": {"match": "any", "rules": [ + {"field": "label", "op": "is", "value": "red"}, {"field": "flag", "op": "is", "value": "reject"}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let keyword = s.catalog.photos().flat_map(|p| p.meta.keywords.clone()).next().expect("a demo keyword"); + let travel = s + .execute( + "album.createSmart", + &serde_json::json!({"name": "Travel", "rules": {"ruleSet": {"rules": [ + {"field": "keywords", "op": "contains", "value": keyword}, {"field": "album", "op": "isNot", "value": excluded}]}}}), + ) + .unwrap()["id"] + .as_u64() + .unwrap(); + let count = |s: &crate::Session| s.catalog.album_count(lightcraft_catalog::AlbumId(travel)); + let want = |s: &crate::Session| { + s.catalog + .photos() + .filter(|p| !p.deleted && p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&keyword.to_lowercase()))) + .filter(|p| p.label != Some(lightcraft_catalog::ColorLabel::Red) && p.flag != lightcraft_catalog::Flag::Reject) + .count() + }; + assert_eq!(count(&s), want(&s)); + // reject one of Travel's photos: it leaves Travel + let before = count(&s); + let id = s.catalog.photos().find(|p| s.catalog.album_contains(lightcraft_catalog::AlbumId(travel), p)).map(|p| p.id).expect("a travel photo"); + s.execute("photo.flag", &serde_json::json!({"ids": [id.0], "flag": "reject"})).unwrap(); + assert_eq!(count(&s), before - 1); + // Excluded Photos testing Travel back would include itself + let looped = s.execute( + "album.setRules", + &serde_json::json!({"id": excluded, "rules": {"ruleSet": {"rules": [{"field": "album", "op": "is", "value": travel}]}}, "replace": true}), + ); + assert!(looped.is_err_and(|e| e.to_string().contains("would make this album include itself")), "a loop is refused"); + // agents read the album by name in the summary + let list = s.execute("albums.list", &serde_json::json!({})).unwrap(); + let travel_json = list.as_array().unwrap().iter().find(|a| a["id"] == travel).cloned().unwrap(); + assert!(travel_json["rulesText"].as_str().unwrap_or("").contains("album isn't “Excluded Photos”"), "{travel_json}"); +} + +/// "Update Rules from Current Filter" refuses a loop too: showing A ("Album isn't B") and +/// updating B from that view would make B test itself. +#[test] +fn rules_from_the_view_cant_loop() { + let mut s = crate::Session::with_demo(); + let b = s.execute("album.createSmart", &serde_json::json!({"name": "B", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap(); + let rules = serde_json::json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": b}]}}); + let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &serde_json::json!({"kind": "album", "id": a})).unwrap(); + let before = s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart.clone(); + let r = s.execute("album.setRules", &serde_json::json!({"id": b, "fromView": true})); + assert!(r.is_err_and(|e| e.to_string().contains("would make this album include itself")), "refused"); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(b)).unwrap().smart, before, "B unchanged"); + assert!(s.catalog.smart_album_problems(lightcraft_catalog::AlbumId(b)).is_empty()); +} + +/// A rule that stops checking later (the album it tests is deleted) doesn't lock up what doesn't +/// touch it: the filter bar keeps working, and an album's other settings can still be edited. A +/// change to the rules themselves is still checked. +#[test] +fn a_stale_rule_doesnt_block_other_changes() { + let mut s = crate::Session::with_demo(); + let trip = s.execute("album.create", &serde_json::json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules = serde_json::json!({"rules": [{"field": "album", "op": "isNot", "value": trip}]}); + s.execute("library.filter", &serde_json::json!({"ruleSet": rules})).unwrap(); + let smart = + s.execute("album.createSmart", &serde_json::json!({"name": "Not Trip", "rules": {"ruleSet": rules}})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap(); + // the filter bar: a rating still applies on top of the stale rule + s.execute("library.filter", &serde_json::json!({"rating": 3})).unwrap(); + assert_eq!(s.filter.rating, 3); + // the album: a partial edit that leaves its rules alone + s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": {"rating": 2}})).unwrap(); + // changing the rules is still checked + let bad = serde_json::json!({"ruleSet": {"rules": [{"field": "rating", "op": "gte", "value": 9}]}}); + assert!(s.execute("library.filter", &bad).is_err()); + assert!(s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": bad})).is_err()); +} + +/// album.setRules refuses a loop through the album filter as well as through the rules. +#[test] +fn an_album_filter_loop_is_refused() { + let mut s = crate::Session::with_demo(); + let a = s.execute("album.createSmart", &serde_json::json!({"name": "A", "rules": {"rating": 2}})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("album.setRules", &serde_json::json!({"id": a, "rules": {"album": a}})); + assert!(r.is_err_and(|e| e.to_string().contains("include itself")), "refused"); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(a)).unwrap().smart.as_ref().and_then(|f| f.album), None); +} diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 7fb24806b..80d1ae546 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -79,6 +79,22 @@ fn smart_album_from_smart_album_view_keeps_its_rules() { assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); } +/// Saving the view of a smart album keeps the filter bar's settings even when the album holds a +/// rule that no longer checks (it tests an album that has since been deleted). +#[test] +fn smart_album_view_keeps_the_filter_bar_over_a_stale_rule() { + let mut s = Session::with_demo(); + let gone = s.execute("album.create", &json!({"name": "Gone", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let rules = json!({"ruleSet": {"rules": [{"field": "album", "op": "isNot", "value": gone}]}}); + let id = s.execute("album.createSmart", &json!({"name": "Not in Gone", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.delete", &json!({"id": gone})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": id})).unwrap(); + s.execute("library.filter", &json!({"rating": 3})).unwrap(); + let r = s.view_rules(); + assert_eq!(r.rating, 3, "the filter bar's rating survives"); + assert!(r.rule_set.is_some(), "and so do the album's own rules"); +} + #[test] fn virtual_copies_are_independent_and_persist() { let dir = temp_dir("vc"); @@ -256,3 +272,44 @@ fn random_sort_persists_with_the_view() { assert_eq!(s2.sort.seed, 123_456_789); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #501: deleting a populated folder is one reversible, durable catalog edit. +#[test] +fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() { + use lightcraft_catalog::AlbumId; + let dir = temp_dir("delete-folder"); + let mut s = open(&dir); + let make = |s: &mut Session, p| s.execute("album.create", &p).unwrap()["id"].as_u64().unwrap(); + let outer = make(&mut s, json!({"name": "Trips", "folder": true})); + let nested = make(&mut s, json!({"name": "Europe", "folder": true, "parent": outer})); + let album = make(&mut s, json!({"name": "Best", "parent": nested, "addSelected": true})); + let sibling = make(&mut s, json!({"name": "Kept", "addSelected": true})); + let smart = s.execute("album.createSmart", &json!({"name": "Rated", "parent": outer, "rules": {"rating": 4}})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.reorder", &json!({"id": album, "parent": nested})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + let before = s.catalog.to_snapshot(); + let photos = serde_json::to_value(s.catalog.photos().collect::>()).unwrap(); + let undo_before = s.undo.len(); + s.execute("album.delete", &json!({"id": outer})).unwrap(); + for id in [outer, nested, album, smart] { + assert!(s.catalog.album(AlbumId(id)).is_none()); + } + assert!(s.catalog.album(AlbumId(sibling)).is_some()); + assert_eq!(serde_json::to_value(s.catalog.photos().collect::>()).unwrap(), photos); + assert_eq!(s.source, LibrarySource::All, "a deleted descendant cannot remain the source"); + assert_eq!(s.undo.len(), undo_before + 1); + let deleted = s.catalog.to_snapshot(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.to_snapshot(), before, "all album fields and memberships restored"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.catalog.to_snapshot(), deleted); + let undo_after = s.undo.len(); + assert!(s.execute("album.delete", &json!({"id": outer})).is_err()); + assert_eq!(s.catalog.to_snapshot(), deleted, "missing id leaves the catalog unchanged"); + assert_eq!(s.undo.len(), undo_after); + drop(s); + let reopened = open(&dir); + assert_eq!(reopened.catalog.to_snapshot(), deleted, "journal replay preserves the deletion"); + drop(reopened); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/tests_process.rs b/crates/engine/src/tests_process.rs new file mode 100644 index 000000000..d101073ec --- /dev/null +++ b/crates/engine/src/tests_process.rs @@ -0,0 +1,264 @@ +//! Process versions through the commands (`docs/process-versions.md`): new photos and Reset get +//! the latest process, Update to Current Process is one undo step, copies inherit, copy / paste / +//! sync / presets / Auto Sync keep each photo's own, and numbers this build doesn't know survive a +//! save and a sidecar round trip. + +use std::sync::Arc; + +use lightcraft_catalog::{Photo, PhotoId, Source}; +use lightcraft_develop::{DevelopSettings, ProcessVersion}; +use serde_json::json; + +use crate::{EngineError, Session}; + +/// No process is older than V1 yet: 0 (which no LightCraft writes) stands in for one. +const OLDER: ProcessVersion = ProcessVersion(0); +/// A number from a newer LightCraft. +const NEWER: ProcessVersion = ProcessVersion(ProcessVersion::LATEST.0 + 6); + +fn process(s: &Session, id: PhotoId) -> ProcessVersion { + s.develop_of(id).unwrap().process +} + +/// Put photo `id` on process `p` (with exposure `ev`) without going through a command. +fn put(s: &mut Session, id: PhotoId, p: ProcessVersion, ev: f64) { + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.process = p; + d.light.exposure = ev; + s.set_develop(id, d, "test").unwrap(); +} + +fn select(s: &mut Session, ids: &[PhotoId]) { + s.execute("library.select", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn update_info(s: &Session) -> crate::CommandInfo { + s.commands().into_iter().find(|c| c.id == "develop.updateProcess").unwrap() +} + +#[test] +fn new_photos_and_reset_get_the_latest_process() { + let mut s = Session::with_demo(); + assert!(s.catalog.photos().all(|p| p.develop.process == ProcessVersion::LATEST)); + let ids: Vec = s.visible_cloned().into_iter().take(2).collect(); + put(&mut s, ids[0], OLDER, 0.7); + put(&mut s, ids[1], NEWER, -0.3); + // a section or a slider reset leaves the process alone + select(&mut s, &ids[1..]); + s.execute("develop.resetSection", &json!({"section": "light"})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.2})).unwrap(); + s.execute("develop.resetControl", &json!({"control": "light.exposure"})).unwrap(); + let d = s.develop_of(ids[1]).unwrap(); + assert_eq!((d.process, d.light.exposure), (NEWER, 0.0)); + // a full reset is a fresh start on the latest process, also from a newer one + select(&mut s, &ids); + s.execute("develop.reset", &json!({})).unwrap(); + for id in &ids { + assert_eq!(process(&s, *id), ProcessVersion::LATEST); + assert!(!s.catalog.photo(*id).unwrap().is_edited()); + } + // a photo whose default preset's look was recorded on an older process + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(process(&s, ids[0]), OLDER); + let mut p = (**s.catalog.photo(ids[0]).unwrap()).clone(); + let mut look = p.camera_defaults(); + look.light.contrast = 20.0; + look.process = OLDER; + p.import_look = Some(Arc::new(look)); + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: p.id }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + s.execute("develop.reset", &json!({"ids": [ids[0].0]})).unwrap(); + let d = s.develop_of(ids[0]).unwrap(); + assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 20.0)); +} + +#[test] +fn update_to_current_process_is_one_undo_step_and_only_when_needed() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().into_iter().take(3).collect(); + select(&mut s, &ids); + // everything is current: disabled, with the reason + let info = update_info(&s); + assert!(!info.enabled && info.disabled_reason.as_deref().is_some_and(|r| r.contains("already on the current process")), "{info:?}"); + assert!(matches!(s.execute("develop.updateProcess", &json!({})), Err(EngineError::Disabled(..)))); + // in the Photo menu + assert_eq!(info.menu, vec!["Photo"]); + + put(&mut s, ids[0], OLDER, 0.5); + put(&mut s, ids[1], OLDER, -0.5); + put(&mut s, ids[2], NEWER, 0.25); + select(&mut s, &ids); + assert!(update_info(&s).enabled); + let undo = s.undo.len(); + let r = s.execute("develop.updateProcess", &json!({})).unwrap(); + assert_eq!(r, json!({"changed": 2, "process": ProcessVersion::LATEST.0})); + assert_eq!(s.undo.len(), undo + 1, "one undo step"); + assert_eq!(s.undo.last().unwrap().label, "Update to Current Process"); + assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]); + assert_eq!(process(&s, ids[2]), NEWER, "a newer process is never downgraded"); + assert_eq!(s.develop_of(ids[0]).unwrap().light.exposure, 0.5, "the sliders keep their values"); + assert_eq!(s.catalog.photo(ids[1]).unwrap().history.last().unwrap().label, "Update to Current Process"); + assert!(!update_info(&s).enabled, "nothing left to update"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [OLDER; 2]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!([process(&s, ids[0]), process(&s, ids[1])], [ProcessVersion::LATEST; 2]); + // a photo on a newer process alone: nothing to update + select(&mut s, &ids[2..]); + assert!(!update_info(&s).enabled); + // the active photo when nothing else is selected + put(&mut s, ids[0], OLDER, 0.5); + s.selection = crate::Selection { ids: Vec::new(), active: Some(ids[0]) }; + assert!(update_info(&s).enabled); + assert_eq!(s.execute("develop.updateProcess", &json!({})).unwrap()["changed"], 1); +} + +#[test] +fn virtual_copies_inherit_the_process() { + let mut s = Session::with_demo(); + let src = s.visible_cloned()[0]; + put(&mut s, src, NEWER, 0.4); + select(&mut s, &[src]); + let r = s.execute("photo.virtualCopy", &json!({})).unwrap(); + let copy = PhotoId(r["ids"][0].as_u64().unwrap()); + assert_eq!(process(&s, copy), NEWER); + assert!(s.develop_of(copy).unwrap() == s.develop_of(src).unwrap()); +} + +#[test] +fn copy_paste_sync_presets_and_auto_sync_keep_each_photos_process() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().into_iter().take(3).collect(); + let (src, a, b) = (ids[0], ids[1], ids[2]); + put(&mut s, src, NEWER, 1.0); + put(&mut s, b, OLDER, 0.0); + select(&mut s, &[src]); + s.execute("develop.copy", &json!({"groups": lightcraft_develop::SettingsGroup::ALL})).unwrap(); + select(&mut s, &[a, b]); + s.execute("develop.paste", &json!({})).unwrap(); + assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]); + assert_eq!(s.develop_of(b).unwrap().light.exposure, 1.0, "the values are pasted"); + // sync from the active photo + put(&mut s, src, NEWER, -1.0); + s.selection = crate::Selection { ids: vec![src, a, b], active: Some(src) }; + s.execute("develop.sync", &json!({})).unwrap(); + assert_eq!([process(&s, a), process(&s, b)], [ProcessVersion::LATEST, OLDER]); + assert_eq!(s.develop_of(a).unwrap().light.exposure, -1.0); + // a preset made from the photo + select(&mut s, &[src]); + // (not Grain: its integer seed doesn't survive scaling, a separate issue) + let groups: Vec<_> = lightcraft_develop::SettingsGroup::ALL.into_iter().filter(|g| *g != lightcraft_develop::SettingsGroup::Grain).collect(); + let pid = s.execute("preset.create", &json!({"name": "Everything", "groups": groups})).unwrap()["id"].clone(); + put(&mut s, b, OLDER, 0.0); + select(&mut s, &[b]); + s.execute("preset.apply", &json!({"id": pid, "amount": 50})).unwrap(); + assert_eq!(process(&s, b), OLDER); + assert_eq!(s.develop_of(b).unwrap().light.exposure, -0.5, "the preset applied (at 50 %)"); + // Auto Sync carries an edit of the active photo, not its process + s.selection = crate::Selection { ids: vec![src, a], active: Some(src) }; + s.execute("develop.autoSync", &json!({"on": true})).unwrap(); + s.execute("develop.merge", &json!({"settings": {"process": OLDER.0, "light": {"contrast": 15}}})).unwrap(); + assert_eq!(process(&s, src), OLDER, "Apply Settings JSON sets what it names"); + let d = s.develop_of(a).unwrap(); + assert_eq!((d.process, d.light.contrast), (ProcessVersion::LATEST, 15.0)); +} + +#[test] +fn process_numbers_survive_a_reopen_and_develop_get_reports_them() { + let dir = std::env::temp_dir().join(format!("lc-process-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut s = Session::new(); + s.open_library(&dir, true).unwrap(); + let ids: Vec = s.visible_cloned().into_iter().take(2).collect(); + select(&mut s, &ids[..1]); + s.execute("develop.merge", &json!({"settings": {"process": NEWER.0, "light": {"exposure": 0.5}}})).unwrap(); + assert_eq!(s.execute("develop.get", &json!({"id": ids[0].0})).unwrap()["process"], json!(NEWER.0)); + assert_eq!(s.execute("develop.get", &json!({"id": ids[1].0})).unwrap()["process"], json!(ProcessVersion::LATEST.0)); + // it renders (with the newest process this build has) + assert!(s.render_now(ids[0], 48, 48).is_ok()); + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s = Session::new(); + s.open_library(&dir, false).unwrap(); + assert_eq!(s.catalog.to_snapshot(), expect); + assert_eq!(process(&s, ids[0]), NEWER); + assert_eq!(process(&s, ids[1]), ProcessVersion::LATEST); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sidecars_keep_the_process_and_interchange_edits_get_the_latest() { + use crate::sidecar::{DevelopPatch, merge_into, parse_sidecar, sidecar_packet}; + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0]; + // our own settings round-trip with their process; V1 settings say nothing (as before process + // versions, so sidecars written then read as V1) + put(&mut s, id, NEWER, 0.3); + let p = s.catalog.photo(id).unwrap().clone(); + let back = parse_sidecar(&sidecar_packet(&p, &s.catalog), crate::crs::Target::Rendered).unwrap(); + let Some(DevelopPatch::Full(d)) = &back.develop else { panic!("no lc:settings") }; + assert_eq!((d.process, d.light.exposure), (NEWER, 0.3)); + put(&mut s, id, ProcessVersion::V1, 0.3); + let packet = sidecar_packet(s.catalog.photo(id).unwrap(), &s.catalog); + assert!(!packet.contains("process"), "{packet}"); + let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().develop else { panic!("no lc:settings") }; + assert_eq!(d.process, ProcessVersion::V1); + // a damaged process value reads as V1 and costs nothing else: the sidecar's edits still come back + for bad in ["-1", "1.5", "\"two\"", "null", "4294967296"] { + let json = format!(r#"{{"process": {bad}, "light": {{"exposure": 0.5}}}}"#); + let packet = lightcraft_meta::write_xmp(&lightcraft_meta::Metadata::default(), Some(&json)); + let Some(DevelopPatch::Full(d)) = parse_sidecar(&packet, crate::crs::Target::Rendered).unwrap().develop else { + panic!("{bad}: the edit was dropped") + }; + assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.5), "{bad}"); + } + // `crs:` edits (other raw developers, Lightroom): their ProcessVersion numbers another + // renderer and is ignored; mapped like a preset, they leave the photo's process as it is, + // which for a newly imported photo is the latest + let xmp = r#" + "#; + let sc = parse_sidecar(xmp, crate::crs::Target::RawAbsolute).unwrap(); + let Some(DevelopPatch::Partial(partial)) = &sc.develop else { panic!("no crs: edit") }; + assert!(partial.get("process").is_none(), "{partial}"); + let mut fresh = Photo::new(PhotoId(999), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 10, 10, "2026-10-09T00:00:00"); + assert!(merge_into(&mut fresh, &sc, "2026-10-09T00:00:00")); + assert_eq!((fresh.develop.process, fresh.develop.light.exposure), (ProcessVersion::LATEST, 1.1)); + let mut older = fresh.clone(); + older.develop = Arc::new(DevelopSettings { process: OLDER, ..Default::default() }); + assert!(merge_into(&mut older, &sc, "2026-10-09T00:00:00")); + assert_eq!(older.develop.process, OLDER); +} + +#[test] +fn update_to_current_process_checks_named_photos_not_the_selection() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().into_iter().take(3).collect(); + let (old, current, other) = (ids[0], ids[1], ids[2]); + put(&mut s, old, OLDER, 0.5); + // a different, current photo is selected: the menu item is off, but naming the photo works + select(&mut s, &[current]); + assert!(!update_info(&s).enabled); + let undo = s.undo.len(); + assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0]})).unwrap()["changed"], 1); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(process(&s, old), ProcessVersion::LATEST); + assert_eq!(s.develop_of(old).unwrap().light.exposure, 0.5); + // with nothing selected at all, by `ids` or `id` + put(&mut s, old, OLDER, 0.5); + put(&mut s, other, OLDER, 0.0); + s.selection = crate::Selection { ids: Vec::new(), active: None }; + assert!(!update_info(&s).enabled, "no selection"); + assert_eq!(s.execute("develop.updateProcess", &json!({"ids": [old.0, current.0]})).unwrap()["changed"], 1); + assert_eq!(s.execute("develop.updateProcess", &json!({"id": other.0})).unwrap()["changed"], 1); + assert_eq!([process(&s, old), process(&s, other)], [ProcessVersion::LATEST; 2]); + // named photos are validated: all current, unknown, malformed or none + let err = s.execute("develop.updateProcess", &json!({"ids": [current.0]})).unwrap_err(); + assert!(matches!(&err, EngineError::Disabled(_, why) if why.contains("already on the current process")), "{err}"); + for bad in [json!({"ids": [99_999]}), json!({"ids": ["x"]}), json!({"ids": "x"}), json!({"ids": []}), json!({"id": -1})] { + assert!(matches!(s.execute("develop.updateProcess", &bad), Err(EngineError::BadParams { .. })), "{bad}"); + } + // `ids: null` names nothing: the selection decides (and there is none) + assert!(matches!(s.execute("develop.updateProcess", &json!({"ids": null})), Err(EngineError::Disabled(..)))); +} diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs new file mode 100644 index 000000000..22dce0af8 --- /dev/null +++ b/crates/engine/src/tests_sync.rs @@ -0,0 +1,623 @@ +//! Synchronize Folder: bring a library folder up to date with what is on disk (see +//! [`crate::sync`]). +//! +//! Scenarios, in the words of someone whose folder changed outside LightCraft: +//! +//! * Given a folder of the library, when files were added to it (or to a folder inside it), the +//! scan lists them as new; files the library already has, by path or by content, are not new. +//! * When a photo's file was deleted or moved away, the scan lists the photo as missing. +//! * When another app saved a photo's XMP sidecar after the photo came into the library, and the +//! sidecar says something the library doesn't, the scan lists a metadata update; a sidecar older +//! than that, or one that agrees with the library, is not an update. +//! * Scanning changes nothing. +//! * Synchronizing imports the new files; it removes missing photos (to Recently Deleted) and +//! reads metadata updates only when asked. Whatever it does is one undo step. +//! * Only a folder of the library can be synchronized, never the whole startup disk. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use serde_json::{Value, json}; + +use crate::Session; + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-sync-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A small procedural PNG (distinct per seed). +fn write_png(path: &str, seed: u8) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(Path::new(path).parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn sidecar(rating: u8) -> String { + format!( + r#" + +"# + ) +} + +/// Write `a.xmp` beside `a.png`, dated `age` before now. +fn write_sidecar(png: &str, rating: u8, age: Duration) { + let xmp = Path::new(png).with_extension("xmp"); + std::fs::write(&xmp, sidecar(rating)).unwrap(); + let f = std::fs::File::options().write(true).open(&xmp).unwrap(); + f.set_modified(SystemTime::now() - age).unwrap(); +} + +/// A library holding `trip/a.png` and `trip/day1/b.png`, the session clock at a fixed past time. +fn library(dir: &Scratch) -> Session { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/day1/b.png"), 2); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap(); + assert_eq!(s.catalog.len(), 2); + s +} + +fn paths(v: &Value, key: &str) -> Vec { + let mut out: Vec = v[key].as_array().unwrap().iter().map(|c| c["path"].as_str().unwrap().to_string()).collect(); + out.sort(); + out +} + +/// What the library holds: each photo's file, whether it is in the library, and its rating. +fn holdings(s: &Session) -> Vec<(String, bool, u8)> { + let mut v: Vec<(String, bool, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.in_library(), p.rating)).collect(); + v.sort(); + v +} + +fn scan(s: &mut Session, path: &str) -> Value { + s.execute("folder.scanChanges", &json!({"path": path})).unwrap() +} + +#[test] +fn an_unchanged_folder_has_no_changes() { + let dir = Scratch::new("none"); + let mut s = library(&dir); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}"); +} + +#[test] +fn files_added_to_the_folder_or_a_folder_inside_it_are_new() { + let dir = Scratch::new("new"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + write_png(&dir.path("trip/day2/d.png"), 4); + // the same bytes as a photo the library has: not new + std::fs::copy(dir.path("trip/a.png"), dir.path("trip/a-copy.png")).unwrap(); + let before = s.catalog.to_snapshot(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png"), dir.path("trip/day2/d.png")], "{r}"); + assert_eq!(r["duplicates"], 1, "{r}"); + assert_eq!(s.catalog.to_snapshot(), before, "scanning changes nothing"); +} + +#[test] +fn a_photo_whose_file_is_gone_is_missing() { + let dir = Scratch::new("missing"); + let mut s = library(&dir); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "missing"), vec![dir.path("trip/day1/b.png")], "{r}"); + assert!(r["missing"][0]["id"].is_u64()); +} + +#[test] +fn a_sidecar_saved_by_another_app_is_a_metadata_update() { + let dir = Scratch::new("meta"); + let mut s = library(&dir); + // saved now: after the photo came in (the session clock says 2026-09-30) + write_sidecar(&dir.path("trip/a.png"), 4, Duration::ZERO); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "metadata"), vec![dir.path("trip/a.png")], "{r}"); +} + +#[test] +fn an_old_sidecar_or_one_that_agrees_is_no_update() { + let dir = Scratch::new("meta-old"); + let mut s = library(&dir); + // older than the import: whatever it says, the library has had its say since + write_sidecar(&dir.path("trip/a.png"), 4, Duration::from_secs(400 * 24 * 3600)); + // new, but says what the library already knows + write_sidecar(&dir.path("trip/day1/b.png"), 0, Duration::ZERO); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "metadata").is_empty(), "{r}"); +} + +#[test] +fn synchronizing_imports_new_files_and_leaves_the_rest_unless_asked() { + let dir = Scratch::new("sync"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(0), Some(0)), "{r}"); + assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 3, "the missing photo stays"); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap(); + assert_eq!(a.rating, 0, "metadata is read only when asked"); +} + +#[test] +fn synchronizing_everything_is_one_undo_step() { + let dir = Scratch::new("sync-all"); + let mut s = library(&dir); + let before = holdings(&s); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO); + let r = + s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "importNew": true, "removeMissing": true, "readMetadata": true})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(1), Some(1)), "{r}"); + let library: Vec = s.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect(); + assert_eq!(library.len(), 2, "b went to Recently Deleted: {library:?}"); + assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5, "the sidecar was read"); + let undo = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(undo["undone"], "Synchronize Folder"); + assert_eq!(holdings(&s), before, "one undo step puts everything back"); +} + +#[test] +fn a_folder_with_no_changes_synchronizes_without_an_undo_step() { + let dir = Scratch::new("sync-none"); + let mut s = library(&dir); + let steps = s.undo.len(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true, "readMetadata": true})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(0), Some(0), Some(0)), "{r}"); + assert_eq!(s.undo.len(), steps); +} + +#[test] +fn only_a_folder_of_the_library_is_synchronized() { + let dir = Scratch::new("refused"); + let mut s = library(&dir); + std::fs::create_dir_all(dir.0.join("elsewhere")).unwrap(); + for (cmd, p, why) in [ + ("folder.scanChanges", json!({}), "missing path"), + ("folder.scanChanges", json!({"path": " "}), "blank path"), + ("folder.scanChanges", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"), + ("folder.scanChanges", json!({"path": "/"}), "the startup disk"), + ("folder.synchronize", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"), + ("folder.synchronize", json!({"path": "/"}), "the startup disk"), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}: {why}"); + } +} + +#[test] +fn synchronizing_acts_on_the_changes_just_scanned() { + let dir = Scratch::new("cached"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "new").len(), 1); + // arrived after the scan the person was shown: not part of what they agreed to + write_png(&dir.path("trip/d.png"), 4); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); + // the scan is used once; the next synchronize looks again + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "d.png now: {r}"); +} + +#[test] +fn a_scan_is_stale_once_the_library_changed() { + let dir = Scratch::new("stale"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + // c.png comes in some other way between the scan and the synchronize + s.execute("library.import", &json!({"paths": [dir.path("trip/c.png")]})).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 0, "the library changed: it looked again: {r}"); +} + +#[test] +fn a_cancelled_scan_stops_checking_files() { + let dir = Scratch::new("cancel"); + let mut s = library(&dir); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap(); + let progress = crate::sync::SyncProgress::default(); + progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + let c = crate::sync::scan_with(input, &progress); + assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}"); +} + +#[test] +fn many_photos_are_checked_in_parallel_with_the_same_answer() { + let dir = Scratch::new("many"); + for i in 0..40u8 { + write_png(&dir.path(&format!("big/{i:02}.png")), i); + } + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [dir.path("big")]})).unwrap(); + for i in (0..40u8).step_by(3) { + std::fs::remove_file(dir.path(&format!("big/{i:02}.png"))).unwrap(); + } + for i in (1..40u8).step_by(5) { + write_sidecar(&dir.path(&format!("big/{i:02}.png")), 2, Duration::ZERO); + } + let r = scan(&mut s, &dir.path("big")); + let expect_missing: Vec = (0..40u8).step_by(3).map(|i| dir.path(&format!("big/{i:02}.png"))).collect(); + let expect_meta: Vec = (1..40u8).step_by(5).filter(|i| i % 3 != 0).map(|i| dir.path(&format!("big/{i:02}.png"))).collect(); + assert_eq!(paths(&r, "missing"), expect_missing); + assert_eq!(paths(&r, "metadata"), expect_meta); +} + +#[test] +fn a_folder_that_is_not_there_is_not_scanned_as_if_everything_went_missing() { + let dir = Scratch::new("offline"); + let mut s = library(&dir); + // an unplugged disk, or a folder moved away whole + std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap(); + for cmd in ["folder.scanChanges", "folder.synchronize"] { + let e = s.execute(cmd, &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap_err().to_string(); + assert!(e.contains("not there"), "{cmd}: {e}"); + } + assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was removed"); +} + +#[test] +fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() { + let dir = Scratch::new("disks"); + let mut s = library(&dir); + // a library photo on another disk makes the scratch folder's root look like it holds disks + let id = s.catalog.alloc_photo_id(); + let p = lightcraft_catalog::Photo::new( + id, + lightcraft_catalog::Source::File { path: "/Volumes/nas/x.jpg".into() }, + "x.jpg", + "JPEG", + 6, + 4, + "2026-01-01", + ); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + if std::path::Path::new("/Volumes").is_dir() { + let e = s.execute("folder.scanChanges", &json!({"path": "/Volumes"})).unwrap_err().to_string(); + assert!(e.contains("disk: true"), "{e}"); + } + // a folder inside one disk needs nothing more + assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok()); +} + +#[test] +fn a_file_only_browsed_in_local_is_new_and_joins_the_library() { + let dir = Scratch::new("local"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + // looking at the folder in Local catalogues c.png as a browse record, not a library photo + s.execute("library.browse", &json!({"path": dir.path("trip")})).unwrap(); + assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.local)); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png")], "{r}"); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); + assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.in_library())); +} + +#[test] +fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() { + let dir = Scratch::new("moved"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap(); + std::fs::rename(dir.path("trip/a.png"), dir.path("trip/day1/a-renamed.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}"); + assert_eq!(r["moved"][0]["id"], a.0, "{r}"); + assert_eq!(r["moved"][0]["to"], dir.path("trip/day1/a-renamed.png"), "{r}"); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap(); + assert_eq!((r["relinked"].as_u64(), r["removed"].as_u64(), r["imported"].as_u64()), (Some(1), Some(0), Some(0)), "{r}"); + let p = s.catalog.photo(a).unwrap(); + assert!(p.in_library() && p.rating == 4, "the same photo, edits and all"); + assert_eq!(p.source, lightcraft_catalog::Source::File { path: dir.path("trip/day1/a-renamed.png") }); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") }); +} + +#[test] +fn synchronizing_is_one_undo_step_even_with_a_full_undo_history() { + let dir = Scratch::new("full-undo"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + // a long session: the history is at its limit and drops its oldest steps + for i in 0..1005u32 { + s.execute("photo.rate", &json!({"ids": [a.0], "rating": i % 5})).unwrap(); + } + let before = holdings(&s); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)), "{r}"); + let undo = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(undo["undone"], "Synchronize Folder"); + assert_eq!(holdings(&s), before, "one step undoes the import and the removal"); +} + +#[test] +fn a_scan_stays_current_while_only_other_folders_change() { + let dir = Scratch::new("current"); + let mut s = library(&dir); + write_png(&dir.path("other/x.png"), 9); + s.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap(); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + // something elsewhere in the library changes while the dialog is open (a background import…) + let x = s.catalog.photos().find(|p| p.file_name == "x.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [x.0], "rating": 3})).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); +} + +#[test] +fn a_change_to_the_folders_photos_makes_the_scan_stale() { + let dir = Scratch::new("stale-folder"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap(); + // the dialog asks for exactly what it showed: refused, never redone on the spot + let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap_err().to_string(); + assert!(e.contains("scan again"), "{e}"); + assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was done"); + // an agent that just asks to synchronize gets a fresh scan + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); +} + +#[test] +fn the_progress_counts_the_photos_checked_too() { + use std::sync::atomic::Ordering::Relaxed; + let dir = Scratch::new("progress"); + let mut s = library(&dir); + let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap(); + let progress = crate::sync::SyncProgress::default(); + crate::sync::scan_with(input, &progress); + assert_eq!((progress.checked.load(Relaxed), progress.to_check.load(Relaxed)), (2, 2)); + let (done, total) = progress.counts(); + assert!(done == total && total >= 2, "{done}/{total}"); +} + +#[test] +fn a_sidecar_the_library_wrote_itself_is_no_update() { + let dir = Scratch::new("autowrite"); + let mut s = library(&dir); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 3})).unwrap(); + s.execute("photo.label", &json!({"ids": [a.0], "label": "green"})).unwrap(); + assert!(Path::new(&dir.path("trip/a.xmp")).exists(), "auto-write wrote the sidecar"); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "metadata").is_empty(), "{r}"); +} + +#[test] +fn a_renamed_photo_with_no_content_hash_is_relinked_when_its_file_is_unmistakable() { + // as photos taken over from a Lightroom catalog are: no hash on record + let dir = Scratch::new("nohash"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + let mut p = (**s.catalog.photo(a).unwrap()).clone(); + p.content_hash = None; + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: a }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + std::fs::rename(dir.path("trip/a.png"), dir.path("trip/a-renamed.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(r["moved"][0]["to"], dir.path("trip/a-renamed.png"), "{r}"); + assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}"); +} + +#[test] +fn renamed_duplicates_are_each_relinked() { + let dir = Scratch::new("dups"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.duplicate", &json!({"ids": [a.0]})).unwrap(); + assert!(Path::new(&dir.path("trip/a-copy.png")).exists()); + std::fs::rename(dir.path("trip/a.png"), dir.path("trip/one.png")).unwrap(); + std::fs::rename(dir.path("trip/a-copy.png"), dir.path("trip/two.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(r["moved"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(paths(&r, "missing").is_empty(), "{r}"); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["relinked"], 2, "{r}"); +} + +#[test] +fn face_regions_found_meanwhile_leave_the_scan_current() { + let dir = Scratch::new("faces"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + // background face search adds a region to a photo of the folder + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + let mut meta = s.catalog.photo(a).unwrap().meta.clone(); + meta.regions.push(lightcraft_meta::Region { rect: Default::default(), kind: lightcraft_meta::RegionKind::Face, name: None, description: None }); + s.catalog.apply(lightcraft_catalog::Op::SetMeta { id: a, meta: Box::new(meta) }).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); +} + +/// Synchronizing in halves, as the app does it: readied on the UI thread without touching the +/// disk, the work (every file read) on a worker, each step committed back on the UI thread. +fn sync_in_halves(s: &mut Session, path: &str, choice: crate::sync::SyncChoice) -> crate::sync::SyncReport { + let changes = crate::sync::scan(s, path, false).unwrap(); + let (work, mut commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap(); + let progress = crate::sync::SyncProgress::default(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || work.run(&progress, |step| tx.send(step).is_ok())).join().unwrap(); + for step in rx { + commit.apply(s, step); + } + commit.finish(s) +} + +#[test] +fn the_work_of_synchronizing_runs_apart_from_its_commits() { + let dir = Scratch::new("halves"); + let mut s = library(&dir); + let before = holdings(&s); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO); + let choice = crate::sync::SyncChoice { import_new: true, relink_moved: true, remove_missing: true, read_metadata: true }; + let r = sync_in_halves(&mut s, &dir.path("trip"), choice); + assert_eq!((r.imported, r.removed, r.read), (1, 1, 1), "{r:?}"); + assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(holdings(&s), before, "still one undo step"); +} + +#[test] +fn the_work_checks_again_that_a_new_file_is_still_there() { + let dir = Scratch::new("ready"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap(); + // the file goes away between readying (UI thread) and the work (worker): the work finds out + let (work, mut commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap(); + std::fs::remove_file(dir.path("trip/c.png")).unwrap(); + let mut steps = Vec::new(); + work.run(&crate::sync::SyncProgress::default(), |step| { + steps.push(step); + true + }); + for step in steps { + commit.apply(&mut s, step); + } + let r = commit.finish(&mut s); + assert_eq!(r.imported, 0, "{r:?}"); + assert_eq!(r.failed.len(), 1, "{r:?}"); +} + +#[test] +fn a_cancelled_synchronize_does_nothing_more() { + let dir = Scratch::new("cancel-run"); + let mut s = library(&dir); + let steps_before = s.undo.len(); + write_png(&dir.path("trip/c.png"), 3); + let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap(); + let (work, commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap(); + let progress = crate::sync::SyncProgress::default(); + progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + let mut sent = 0; + work.run(&progress, |_| { + sent += 1; + true + }); + assert_eq!(sent, 0); + let r = commit.finish(&mut s); + assert_eq!((r.imported, s.undo.len()), (0, steps_before)); +} + +/// Ready a synchronize of the trip folder and do its work, keeping the steps for the test to +/// commit when it likes. +fn readied(s: &mut Session, dir: &Scratch, choice: crate::sync::SyncChoice) -> (Vec, crate::sync::SyncCommit) { + let changes = crate::sync::scan(s, &dir.path("trip"), false).unwrap(); + let (work, commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap(); + let mut steps = Vec::new(); + work.run(&crate::sync::SyncProgress::default(), |step| { + steps.push(step); + true + }); + (steps, commit) +} + +#[test] +fn steps_for_a_library_that_was_closed_meanwhile_are_not_applied() { + let dir = Scratch::new("switched"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() }; + let (steps, mut commit) = readied(&mut s, &dir, choice); + // File › Open Library while the work runs: another catalog, whose photo ids mean other photos + s.library_identity = std::sync::Arc::new(()); + let before = holdings(&s); + let undo = s.undo.len(); + for step in steps { + commit.apply(&mut s, step); + } + let r = commit.finish(&mut s); + assert_eq!(holdings(&s), before, "nothing landed in the other library"); + assert_eq!(s.undo.len(), undo); + assert_eq!((r.imported, r.removed), (0, 0), "{r:?}"); + assert!(!r.failed.is_empty(), "and the report says why: {r:?}"); +} + +#[test] +fn an_undo_pressed_while_synchronizing_is_not_folded_into_it() { + let dir = Scratch::new("undo-mid"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap(); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() }; + let (mut steps, mut commit) = readied(&mut s, &dir, choice); + assert!(steps.len() >= 2, "an import step and a remove step"); + let first = steps.remove(0); + commit.apply(&mut s, first); + // the person presses Undo between two steps of the run + s.execute("edit.undo", &json!({})).unwrap(); + for step in steps { + commit.apply(&mut s, step); + } + commit.finish(&mut s); + // undoing the run's last step never takes the rating with it + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(a).unwrap().rating, 4, "the earlier, unrelated step stays"); +} + +#[test] +fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() { + let dir = Scratch::new("namings"); + let mut s = library(&dir); + // a raw beside its JPEG: the raw owns IMG_1.xmp, the JPEG's is IMG_1.JPG.xmp + for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] { + let id = s.catalog.alloc_photo_id(); + let p = lightcraft_catalog::Photo::new( + id, + lightcraft_catalog::Source::File { path: dir.path(&format!("trip/{name}")) }, + name, + "X", + 6, + 4, + "2026-01-01", + ); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let ids: Vec = s.catalog.photos().map(|p| p.id).collect(); + let one_by_one: Vec<_> = ids.iter().map(|id| s.sidecar_naming(*id)).collect(); + assert_eq!(s.sidecar_namings(&ids), one_by_one); + assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there"); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index ab801e237..e3137da00 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -94,7 +94,8 @@ fn auto_write_and_naming_preference() { // a slider drag writes once, at the end s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); s.execute("develop.set", &json!({"control": "light.exposure", "value": 1.5})).unwrap(); - let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), false).unwrap().develop { + let exposure = |p: &Path| match crate::sidecar::parse_sidecar(&std::fs::read_to_string(p).unwrap(), crate::crs::Target::Rendered).unwrap().develop + { Some(crate::sidecar::DevelopPatch::Full(d)) => d.light.exposure, other => panic!("{other:?}"), }; diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 16c9f8a0c..5b32999bb 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -212,7 +212,7 @@ pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec { _ => {} } if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) { - add(format!("Rules: {}", rs.describe()), json!({"ruleSet": Null})); + add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null})); } if !f.only.is_empty() { add(format!("Only {} photos", f.only.len()), json!({"only": []})); diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index b19acf544..dc348ee88 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -30,3 +30,4 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"] [dev-dependencies] lightcraft-scenes = { workspace = true } +lightcraft-raw = { workspace = true } diff --git a/crates/gpu/src/exit.rs b/crates/gpu/src/exit.rs new file mode 100644 index 000000000..79f114ef1 --- /dev/null +++ b/crates/gpu/src/exit.rs @@ -0,0 +1,132 @@ +//! Leaving: no GPU work may be in flight when the process ends (issue #620). +//! +//! The devices are statics that are never dropped, and the threads that render (preview workers, +//! export, denoise, the device warm-up) are not the one that ends the process. Exiting runs the +//! driver's own teardown while those threads keep running: a render that is inside the driver at +//! that moment crashes there (seen as a SIGSEGV in the NVIDIA Vulkan driver), where neither the +//! panic hook nor `catch_unwind` can help. +//! +//! So every entry into a device goes through [`enter`], and the host ends the process with +//! [`crate::begin_shutdown`] (nothing new enters) and [`crate::wait_idle`] (what is in flight +//! finishes, up to a deadline). + +use std::sync::{Condvar, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +struct State { + closed: bool, + /// [`Work`] in flight, on any thread. + busy: usize, +} + +pub(crate) struct Gate { + state: Mutex, + idle: Condvar, +} + +/// GPU work in flight (until dropped). +pub(crate) struct Work<'a>(&'a Gate); + +static GATE: Gate = Gate::new(); + +/// Start GPU work, or `None` when the process is ending: the caller then does without the GPU. +/// Work may nest (a render that creates the device). +pub(crate) fn enter() -> Option> { + GATE.enter() +} + +pub(crate) fn begin_shutdown() { + GATE.close(); +} + +pub(crate) fn shutting_down() -> bool { + GATE.lock().closed +} + +pub(crate) fn wait_idle(timeout: Duration) -> bool { + GATE.wait_idle(timeout) +} + +impl Gate { + pub const fn new() -> Gate { + Gate { state: Mutex::new(State { closed: false, busy: 0 }), idle: Condvar::new() } + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + pub fn enter(&self) -> Option> { + let mut s = self.lock(); + if s.closed { + return None; + } + s.busy = s.busy.saturating_add(1); + Some(Work(self)) + } + + /// Nothing enters from now on. + pub fn close(&self) { + self.lock().closed = true; + } + + /// Wait up to `timeout` until no work is in flight; `false` when some still is. + pub fn wait_idle(&self, timeout: Duration) -> bool { + let t0 = Instant::now(); + let mut s = self.lock(); + while s.busy > 0 { + let Some(left) = timeout.checked_sub(t0.elapsed()).filter(|d| !d.is_zero()) else { return false }; + s = self.idle.wait_timeout(s, left).unwrap_or_else(|e| e.into_inner()).0; + } + true + } +} + +impl Drop for Work<'_> { + fn drop(&mut self) { + let mut s = self.0.lock(); + s.busy = s.busy.saturating_sub(1); + if s.busy == 0 { + drop(s); + self.0.idle.notify_all(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn closed_gate_lets_nothing_in_and_waits_for_what_is_inside() { + let gate = Gate::new(); + assert!(gate.wait_idle(Duration::ZERO), "idle: no wait"); + let outer = gate.enter().unwrap(); + let inner = gate.enter().unwrap(); + gate.close(); + assert!(gate.enter().is_none(), "nothing new once closed"); + drop(inner); + let t0 = Instant::now(); + assert!(!gate.wait_idle(Duration::from_millis(50)), "the outer work is still in flight"); + assert!(t0.elapsed() >= Duration::from_millis(50)); + std::thread::scope(|s| { + s.spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + drop(outer); + }); + assert!(gate.wait_idle(Duration::from_secs(30)), "woken when the work ends"); + }); + assert!(gate.enter().is_none()); + } + + #[test] + fn work_that_panics_still_leaves() { + let gate = Gate::new(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _work = gate.enter(); + panic!("a render panics (expected in this test)"); + })); + assert!(r.is_err()); + assert!(gate.wait_idle(Duration::ZERO)); + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 9c2349643..1eae995a4 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -15,6 +15,8 @@ //! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`). //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here //! compiles to the CPU fallback on wasm32. +//! +//! Before the process ends: [`begin_shutdown`], then [`wait_idle`] (issue #620, see `exit.rs`). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -29,6 +31,8 @@ use lightcraft_raster::Rgb32f; pub mod backend; #[cfg(not(target_arch = "wasm32"))] mod ctx; +#[cfg(not(target_arch = "wasm32"))] +mod exit; #[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] pub mod nn; #[cfg(not(target_arch = "wasm32"))] @@ -77,6 +81,9 @@ pub fn unavailable_reason() -> Option { if !ENABLED.load(Ordering::Relaxed) { return Some("disabled by the GPU rendering preference (app.gpu)".into()); } + if shutting_down() { + return Some("LightCraft is closing".into()); + } if BROKEN.load(Ordering::Relaxed) { let r = BROKEN_REASON.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_else(|| "device error".into()); return Some(format!("stopped after a GPU failure: {r}")); @@ -147,6 +154,41 @@ pub fn enabled() -> bool { ENABLED.load(Ordering::Relaxed) && !BROKEN.load(Ordering::Relaxed) && !env_disabled() } +/// The process is about to end: no GPU work starts from now on, on any thread. [`render`] returns +/// `None` (callers render on the CPU), the devices are not created and denoise tiles fail over to +/// the CPU. There is no way back. Follow it with [`wait_idle`]. +pub fn begin_shutdown() { + #[cfg(not(target_arch = "wasm32"))] + exit::begin_shutdown(); +} + +/// Whether [`begin_shutdown`] was called. +pub fn shutting_down() -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + exit::shutting_down() + } + #[cfg(target_arch = "wasm32")] + { + false + } +} + +/// Wait up to `timeout` for the GPU work in flight on every thread to end. `true`: none is left, +/// and after [`begin_shutdown`] none can start, so the process may end; `false`: the deadline +/// passed with a render (or a device being created) still inside the driver. +pub fn wait_idle(timeout: std::time::Duration) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + exit::wait_idle(timeout) + } + #[cfg(target_arch = "wasm32")] + { + let _ = timeout; + true + } +} + /// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the /// environment switches and the rendering preference, but not a render-device failure (the devices are separate). #[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] @@ -177,6 +219,11 @@ pub(crate) fn device() -> Option<&'static ctx::Gpu> { if env_disabled() || !ENABLED.load(Ordering::Relaxed) { return existing_device(); } + if let Some(g) = GPU.get() { + return g.as_ref().ok(); + } + // creating the device is a long call into the driver: not once the process is ending + let _work = exit::enter()?; GPU.get_or_init(|| { let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; backend::with_init_marker(backends, || { @@ -281,7 +328,9 @@ pub fn set_pool_limit(bytes: u64) { /// Free recycled buffers until at most `keep` bytes stay pooled (e.g. when the app goes idle). pub fn trim_pool(keep: u64) { #[cfg(not(target_arch = "wasm32"))] - if let Some(g) = existing_device() { + if let Some(g) = existing_device() + && let Some(_work) = exit::enter() + { if keep == 0 { render::release_shared_source(); } @@ -337,9 +386,17 @@ pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &R let effective = s.effective(); let s: &DevelopSettings = &effective; // the kernel writes 8-bit output: high-bit-depth exports (and soft proofs) render on the CPU - if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 || req.proof.is_some() { + // Visualize HDR needs the float HDR render: CPU too + if !enabled() + || req.depth != lightcraft_pipeline::OutputDepth::U8 + || req.proof.is_some() + || req.overlay == lightcraft_pipeline::Overlay::HdrRange + { return None; } + let s = &*lightcraft_pipeline::settings_for(s, req); + // in flight until this returns; `None` once the process is ending (issue #620) + let _work = exit::enter()?; let gpu = device()?; let ext = stages.map(|c| c.extension::()); let fault = take_fault(); diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index bb9d5eea7..689aecbec 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -63,9 +63,17 @@ fn dev() -> Result<&'static Dev, String> { if let Some(why) = crate::switched_off() { return Err(why); } + // creating the device is GPU work too: not once the process is ending (issue #620) + let _work = match DEV.get() { + Some(_) => None, + None => Some(crate::exit::enter().ok_or(CLOSING)?), + }; DEV.get_or_init(create_device).as_ref().map_err(|e| e.clone()) } +/// Why nothing runs on the GPU once [`crate::begin_shutdown`] was called. +const CLOSING: &str = "LightCraft is closing"; + fn create_device() -> Result { let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; crate::backend::with_init_marker(backends, || { @@ -511,7 +519,6 @@ fn conv_source(bm: u32, bn: u32, bk: u32) -> String { .replace("{{BM}}", &bm.to_string()) .replace("{{BN}}", &bn.to_string()) .replace("{{BK}}", &bk.to_string()) - .replace("{{A_VECS}}", &(bk * bm / 4).to_string()) .replace("{{B_VECS}}", &(bk * bn / 4).to_string()) } @@ -654,6 +661,7 @@ pub struct NetRunner { /// Put `net` on the GPU for `tile × tile` input cells, or say why it cannot be (no GPU, a layer or a size the kernels /// do not do, the device refusing the buffers). Creates the device on first use and compiles the kernels. pub fn runner(net: &Net, tile: usize) -> Result { + let _work = crate::exit::enter().ok_or(CLOSING)?; let d = dev()?; let max_binding = d.limits.max_storage_buffer_binding_size.min(d.limits.max_buffer_size).min(MAX_BUFFER); let (layout, layers) = plan(net, tile, max_binding)?; @@ -840,6 +848,8 @@ impl TileRunner for NetRunner { if input.len() != self.shared.layout.in_floats { return Err(Error::Input("the tile is not the size the model wants".into())); } + // one tile is one piece of GPU work: a photo's worth would outlast any wait at exit + let Some(_work) = crate::exit::enter() else { return Err(Error::Runtime(CLOSING.into())) }; let w = self.take()?; let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.execute(&w, input))); match r { @@ -902,6 +912,7 @@ mod tests { fn worst(a: &[f32], b: &[f32]) -> f32 { assert_eq!(a.len(), b.len()); + assert!(a.iter().chain(b).all(|v| v.is_finite()), "non-finite denoise output"); let scale = b.iter().fold(1e-3f32, |m, v| m.max(v.abs())); a.iter().zip(b).fold(0f32, |m, (x, y)| m.max((x - y).abs())) / scale } @@ -925,6 +936,21 @@ mod tests { } } + // Issue #479: several pixels loaded by different invocations shared a vec4, + // so lane writes raced on Metal. Exercise a partial block repeatedly with new + // inputs; stale shared-memory values must never leak into the next dispatch. + #[test] + fn partial_tiles_remain_correct_when_reusing_a_runner() { + let n = net(3, 4, 0, 8); + let Some(g) = gpu(&n, 3) else { return }; + for seed in 100..112 { + let x = picture(4 * 3 * 3, seed); + let want = reference::run(&n, 3, &x).unwrap(); + let e = worst(&g.run(&x).unwrap(), &want); + assert!(e < 2e-4, "seed {seed}: off by {e:e}"); + } + } + #[test] fn a_runner_serves_many_tiles_from_many_threads() { let n = net(32, 8, 2, 7); @@ -1022,7 +1048,6 @@ mod tests { .replace("{{BM}}", &bm.to_string()) .replace("{{BN}}", &bn.to_string()) .replace("{{BK}}", &bk.to_string()) - .replace("{{A_VECS}}", &(bk * bm / 4).to_string()) .replace("{{B_VECS}}", &(bk * bn / 4).to_string()); let d = dev().unwrap(); let layout = conv_bgl(d); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 4a7c27ce6..45a5a5f6d 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -639,7 +639,10 @@ pub fn render( lap("masks", &mut t, &mut cx); // 5. per-pixel stage - let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + if let Some(d) = &req.display { + fp.for_display(d, None); + } let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), diff --git a/crates/gpu/src/wgsl/nn_conv.wgsl b/crates/gpu/src/wgsl/nn_conv.wgsl index d991e9e76..20a0ce996 100644 --- a/crates/gpu/src/wgsl/nn_conv.wgsl +++ b/crates/gpu/src/wgsl/nn_conv.wgsl @@ -44,8 +44,11 @@ const TX: u32 = BN / 4u; const A_ITEMS: u32 = BM * (BK / 4u); const B_ITEMS: u32 = BK * (BN / 4u); -// A[k][m] four pixels to a vec4, B[k][n] four channels to a vec4 -var As: array, {{A_VECS}}>; +// A[k][m] has one scalar per pixel; B[k][n] packs four channels per vec4. +// Different invocations must not write lanes of the same shared vector: WGSL permits +// a component write to access the entire vector. A later barrier cannot undo that race. +// Each scalar below has one writer; gather four pixels only after the barrier. +var As: array; var Bs: array, {{B_VECS}}>; @compute @workgroup_size(256) @@ -88,12 +91,11 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t } } } - let base = (q * 4u) * (BM / 4u) + ml / 4u; - let lane = ml % 4u; - As[base][lane] = v.x; - As[base + BM / 4u][lane] = v.y; - As[base + 2u * (BM / 4u)][lane] = v.z; - As[base + 3u * (BM / 4u)][lane] = v.w; + let base = q * 4u * BM + ml; + As[base] = v.x; + As[base + BM] = v.y; + As[base + 2u * BM] = v.z; + As[base + 3u * BM] = v.w; } for (var item = tid; item < B_ITEMS; item += 256u) { let r = item / (BN / 4u); @@ -103,7 +105,8 @@ fn main(@builtin(workgroup_id) wg: vec3, @builtin(local_invocation_index) t } workgroupBarrier(); for (var kk = 0u; kk < BK; kk++) { - let a = As[kk * (BM / 4u) + ty]; + let ai = kk * BM + ty * 4u; + let a = vec4(As[ai], As[ai + 1u], As[ai + 2u], As[ai + 3u]); let b = Bs[kk * TX + tx]; acc0 += a.x * b; acc1 += a.y * b; diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index cb6e7c024..b10836c37 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -406,6 +406,59 @@ fn gpu_matches_cpu() { eprintln!("worst: mean {:.4} LSB, max {} LSB", worst.0, worst.1); } +/// Issue #523: a raw's clipped highlights rebuilt as the raw decode does it (fully clipped ones to the neutral of +/// the colour model applied after them, here a look whose rows don't sum to one) stay neutral when darkened, on +/// the GPU as on the CPU, and the two renders agree. +#[test] +fn clipped_highlights_stay_neutral_on_both() { + if !gpu() { + return; + } + let wb = [2.4f32, 1.0, 1.6]; + let look = lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]); + let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::(); + let n = look.inverse().unwrap().apply([1.0; 3]); + let white = n.map(|v| (v * luma(look.apply([1.0; 3])) / luma(look.apply(n))) as f32); + // a grey wall with a fully clipped light, a light clipped in green only, and a dark bar with a purple fringe + // in front of the second + let mut cam = Rgb32f::from_fn(320, 240, |x, y| { + let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 120.0).powi(2)).sqrt(); + if d(90.0) < 50.0 { + [1.0; 3] + } else if (226..232).contains(&x) && (70..170).contains(&y) { + [0.02, 0.03, 0.02] + } else if (x == 225 || x == 232) && (70..170).contains(&y) { + [0.3, 0.25, 0.6] + } else if d(228.0) < 50.0 { + [0.6 / wb[0], 1.0, 0.7 / wb[2]] + } else { + [0.3 / wb[0], 0.3, 0.3 / wb[2]] + } + }); + lightcraft_raw::highlight::reconstruct_with(&mut cam, wb, 0.99, white); + let m = look.to_f32(); + let src = Arc::new(cam.map(|p| { + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + std::array::from_fn(|i| (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]).max(0.0)) + })); + let info = SourceInfo { raw: true, ..Default::default() }; + let req = RenderRequest::fit(320, 240); + let mut h100 = DevelopSettings::default(); + h100.light.highlights = -100.0; + let mut issue = DevelopSettings::default(); + let l = &mut issue.light; + (l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0); + for (name, s) in [("clipped highlights -100", h100), ("clipped highlights #523", issue)] { + check(name, &src, &info, &s, &req); + let cpu = render(&src, &info, &s, &req).image; + let gpu = lightcraft_gpu::render(&src, &info, &s, &req, None).expect("gpu render").image; + for (who, img) in [("CPU", &cpu), ("GPU", &gpu)] { + let p = img.get(90, 120); + assert!(p[1] < 250 && p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]) <= 1, "{name} {who}: clipped light {p:?}"); + } + } +} + #[test] fn rendered_sources_and_other_scenes() { if !gpu() { diff --git a/crates/gpu/tests/shutdown.rs b/crates/gpu/tests/shutdown.rs new file mode 100644 index 000000000..b827a0711 --- /dev/null +++ b/crates/gpu/tests/shutdown.rs @@ -0,0 +1,58 @@ +//! Issue #620: a render still inside the GPU driver while the process exits crashes there (a +//! SIGSEGV no panic hook catches). Once `begin_shutdown` is called nothing starts on the GPU, and +//! `wait_idle` returns when the renders in flight on other threads have ended. +//! One test: the shutdown holds for the whole process. Without a GPU adapter only the CPU side of +//! the contract is checked. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +use lightcraft_develop::DevelopSettings; +use lightcraft_pipeline::{RenderRequest, SourceInfo}; + +#[test] +fn nothing_renders_on_the_gpu_once_the_process_is_ending() { + let (w, h) = (600, 400); + let src = Arc::new(lightcraft_scenes::demo_library()[0].render(w, h)); + let info = SourceInfo { raw: true, ..Default::default() }; + let mut s = DevelopSettings::default(); + s.light.exposure = 0.3; + let req = RenderRequest::fit(w, h); + let has_gpu = lightcraft_gpu::available(); + if !has_gpu { + eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason()); + } + assert!(!lightcraft_gpu::shutting_down()); + assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet"); + + // a worker that renders until the GPU refuses, as the preview workers do while the app quits + let (rendered, refused) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicBool::new(false))); + let worker = { + let (src, info, s) = (src.clone(), info.clone(), s.clone()); + let (rendered, refused) = (rendered.clone(), refused.clone()); + std::thread::spawn(move || { + while lightcraft_gpu::render(&src, &info, &s, &req, None).is_some() { + rendered.fetch_add(1, Ordering::SeqCst); + } + refused.store(true, Ordering::SeqCst); + }) + }; + if has_gpu { + while rendered.load(Ordering::SeqCst) == 0 && !refused.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + assert!(rendered.load(Ordering::SeqCst) > 0, "the GPU renders before the shutdown ({:?})", lightcraft_gpu::last_fallback()); + } + + lightcraft_gpu::begin_shutdown(); + assert!(lightcraft_gpu::shutting_down()); + assert!(lightcraft_gpu::wait_idle(Duration::from_secs(60)), "the render in flight ends"); + worker.join().unwrap(); + assert!(refused.load(Ordering::SeqCst)); + assert!(lightcraft_gpu::render(&src, &info, &s, &req, None).is_none(), "no GPU render once the process is ending"); + assert!(lightcraft_gpu::wait_idle(Duration::ZERO)); + if has_gpu { + assert_eq!(lightcraft_gpu::unavailable_reason().as_deref(), Some("LightCraft is closing")); + } +} diff --git a/crates/gpu/tests/shutdown_first.rs b/crates/gpu/tests/shutdown_first.rs new file mode 100644 index 000000000..3810762e6 --- /dev/null +++ b/crates/gpu/tests/shutdown_first.rs @@ -0,0 +1,25 @@ +//! Issue #620: quitting right after the start must not leave the device being created (a long +//! call into the driver, on the `lc-gpu-init` thread) while the process exits. Once +//! `begin_shutdown` is called the device is not created at all. +//! One test in its own process: the shutdown comes before the first use of the GPU. + +use std::sync::Arc; +use std::time::Duration; + +use lightcraft_develop::DevelopSettings; +use lightcraft_pipeline::{RenderRequest, SourceInfo}; + +#[test] +fn the_device_is_not_created_once_the_process_is_ending() { + lightcraft_gpu::begin_shutdown(); + lightcraft_gpu::warm_up(); + assert!(!lightcraft_gpu::available()); + assert_eq!(lightcraft_gpu::adapter_name(), None); + let src = Arc::new(lightcraft_scenes::demo_library()[0].render(300, 200)); + let rendered = lightcraft_gpu::render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(300, 200), None); + assert!(rendered.is_none()); + assert!(lightcraft_gpu::wait_idle(Duration::from_secs(60)), "the warm-up thread found the gate closed"); + // `ready()` is "device creation finished" (or the GPU is switched off by the environment) + let switched_off = lightcraft_gpu::unavailable_reason().is_some_and(|r| r.starts_with("disabled by LIGHTCRAFT_GPU")); + assert!(switched_off || !lightcraft_gpu::ready(), "no device was created"); +} diff --git a/crates/heif/Cargo.toml b/crates/heif/Cargo.toml index 9a12bb2e5..b3bc69d62 100644 --- a/crates/heif/Cargo.toml +++ b/crates/heif/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lightcraft-heif" -description = "Optional HEIF/HEIC still-image decoder for LightCraft (a thin, panic-guarded wrapper around heic-rs)" +description = "Optional HEIF/HEIC still-image decoder for LightCraft (heic-rs for the container and HEVC, libheif-matching colour conversion, panic-guarded)" version.workspace = true edition.workspace = true license.workspace = true @@ -9,12 +9,18 @@ license.workspace = true # Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files; # moving the pin is a reviewed change (re-run the heif tests and the codecs corpus tests). # Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the -# other permissive candidates (heif-oxide lacks 4:2:2; heifer was published the same week). +# other permissive candidates (heif-oxide's HEVC decoder, rust_h265, follows FFmpeg's (LGPL) +# code structure; gamut-heic has no HEVC decoder of its own). heic-rs = { version = "=0.1.1", default-features = false, features = ["std"] } # The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] heic-rs = { version = "=0.1.1", default-features = false, features = ["std", "parallel"] } +rayon = { workspace = true } + +[features] +# `lightcraft_heif::testdata`: lossless synthetic HEIF files, for the tests of the crates above. +testdata = [] [lints] workspace = true diff --git a/crates/heif/README.md b/crates/heif/README.md index e008a01c3..75db814b0 100644 --- a/crates/heif/README.md +++ b/crates/heif/README.md @@ -1,33 +1,47 @@ # lightcraft-heif -The optional HEIF / HEIC decoder of LightCraft: iPhone and Mac photos. A thin, panic-guarded wrapper -around [`heic-rs`](https://github.com/tbraun96/heic-rs), a pure-Rust HEVC still-picture decoder -(no `unsafe`, MIT OR Apache-2.0): single pictures and grid-tiled photos, 8- and 10-bit (10-bit -decodes to 16-bit), alpha auxiliary images, the container's rotation/mirror/crop, ICC, EXIF and XMP. -Read-only: writing would need an HEVC encoder. +The optional HEIF / HEIC decoder of LightCraft: iPhone and Mac photos. [`heic-rs`](https://github.com/tbraun96/heic-rs) +(pure Rust, no `unsafe`, MIT OR Apache-2.0, written from the H.265 / HEIF specifications) reads the +container and reconstructs the HEVC pictures; this crate turns them into 16-bit RGB itself so the +result matches libheif to within one code value: + +- the colour description is the item's `colr nclx` (or its first tile's), else the HEVC VUI + (iPhones: full range), else the H.265 defaults; an unspecified matrix is BT.601. heic-rs alone + assumes BT.709 limited range, about 8 code values off on every iPhone photo; +- chroma is upsampled by nearest neighbour, or centred bilinear when a `clap` starts on an odd + pixel, as libheif does; +- `imir` axis 0 flips top and bottom, as libheif writes and reads it (heic-rs names it the other way); +- a grid's ICC profile may sit on its tiles. + +Single pictures and grid-tiled photos, 4:2:0 and monochrome, 8- to 10-bit, alpha auxiliary images +(other auxiliary images — HDR gain maps, depth — are ignored), the container's rotation/mirror/crop, +ICC, EXIF and XMP, and the file's thumbnail item. 4:2:2 and 4:4:4 (Canon/Sony "HIF") are refused +with `Error::Unsupported`: heic-rs 0.1.1 loses sync on them. Read-only. ```rust let info = lightcraft_heif::probe(&bytes)?; // size, depth, alpha: check limits first let img = lightcraft_heif::decode(&bytes, &lightcraft_heif::Options::default())?; -// img.width, img.height, img.has_alpha, img.sixteen_bit, img.data (RGB/RGBA), img.icc, img.exif, img.xmp +// img.width, img.height, img.has_alpha, img.bit_depth, img.data (RGB/RGBA u16), img.colour, img.icc, img.exif, img.xmp +let thumb = lightcraft_heif::decode_thumbnail(&bytes, 256, &Default::default())?; // Option ``` -Errors are `Error::Unsupported` (image sequences, overlays, …), `Error::Limit` or +Errors are `Error::Unsupported` (image sequences, overlays, 4:2:2/4:4:4, …), `Error::Limit` or `Error::Malformed`. It never panics: every call into heic-rs runs under `catch_unwind`, and a panic -inside it (PhotoCraft's fuzzing found two in 0.1.1) becomes `Error::Malformed`. The crate is a port -of PhotoCraft's `photocraft-heif` — the sibling apps deliberately share one decoder, one pin and -one set of regression fixtures. +inside it (PhotoCraft's fuzzing found two in 0.1.1) becomes `Error::Malformed`. + +Feature `testdata` exposes `lightcraft_heif::testdata`: lossless synthetic HEIF files (PCM-coded +32 × 32 HEVC pictures, grids, every property above) for the tests of the crates above. ## Why it is a separate, optional crate - **A young decoder.** heic-rs is new and has a single maintainer. It is pinned exactly (`=0.1.1`); moving the pin is a reviewed change (re-run the crate tests and - `cargo test -p lightcraft-codecs --features heif`). -- **HEVC patents are a distributor's call.** HEVC is patent-encumbered in some jurisdictions, so - whether a build includes an HEVC decoder is a build-time choice. `lightcraft-codecs` uses this - crate only behind its non-default `heif` feature; the apps forward it - (`cargo build -p lightcraft --features heif`). Without it, HEIC files are still recognised and - opening one is a clear "HEIC/HEIF support isn't included in this build" error — the same policy - as PhotoCraft. + `cargo test -p lightcraft-codecs --features heif`). The other permissive candidates were + rejected: heif-oxide's HEVC decoder (rust_h265) follows FFmpeg's (LGPL) code structure, and + gamut-heic has no HEVC decoder of its own. +- **HEVC patents are a distributor's call.** `lightcraft-codecs` uses this crate only behind its + non-default `heif` feature; the apps forward it and the release packages enable it. Without it, + HEIC files are still recognised and opening one is a clear "HEIC/HEIF support isn't included in + this build" error — the same policy as PhotoCraft. The crate depends on no other LightCraft crate; `codecs` → `heif` is the only edge between them. diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 7b19b9b94..8292024b1 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -1,14 +1,17 @@ //! `lightcraft-heif`: the optional HEIF / HEIC (iPhone and Mac photo) decoder. //! -//! A thin wrapper around `heic-rs`, a pure-Rust HEVC still-picture decoder: single pictures and -//! grid-tiled photos, 8- and 10-bit (10-bit decodes to 16-bit), alpha auxiliary images, ICC, -//! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows -//! nothing about the rest of LightCraft; `lightcraft-codecs` adapts it behind its `heif` feature. -//! A port of PhotoCraft's `photocraft-heif` (same team, same licence) so the sibling apps share -//! one decision, one dependency pin and one set of regression fixtures. +//! `heic-rs`, a pure-Rust HEIF container and HEVC still-picture decoder, reads the container and +//! reconstructs the coded pictures (bit-exact against libde265 on iPhone photos); this crate turns +//! them into RGB itself ([`ycc`]) so the colours match libheif: it honours the HEVC VUI's range +//! and matrix ([`vui`]) where heic-rs assumes BT.709 limited range, and upsamples chroma like +//! libheif. Single pictures and grid-tiled photos, 8- to 16-bit, monochrome, alpha auxiliary +//! images, ICC, EXIF and XMP. The API is plain data ([`Info`], [`Decoded`], [`Error`]) so the +//! crate knows nothing about the rest of LightCraft; `lightcraft-codecs` adapts it behind its +//! `heif` feature. Shares heic-rs and its regression fixtures with PhotoCraft's `photocraft-heif`. //! //! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF. //! [`Options::apply_transforms`] applies them; with it off, the pixels come back as coded. +//! Auxiliary images other than alpha (HDR gain maps, depth maps) are ignored. //! //! Never panics: heic-rs is young, so every call into it runs under `catch_unwind` and a panic //! inside it becomes [`Error::Malformed`]. @@ -16,9 +19,20 @@ #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod orient; +/// Lossless synthetic HEIF files for tests, here and in the crates above (feature `testdata`). +#[cfg(any(test, feature = "testdata"))] +#[doc(hidden)] +pub mod testdata; +mod vui; +mod ycc; + use std::borrow::Cow; -use heic_rs::PixelLayout; +use heic_rs::context::Context; +use heic_rs::hevc::Frame; +use heic_rs::props::ItemProps; +use heic_rs::props::colr::Range; /// What a HEIF file declares, read from the container alone (no pixel is decoded). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -31,8 +45,8 @@ pub struct Info { pub coded_height: u32, /// The primary image has an alpha auxiliary image. pub has_alpha: bool, - /// More than 8 bits per sample: it decodes to 16-bit. - pub sixteen_bit: bool, + /// Bits per sample as coded (8 for most photos, 10 for HDR-capable ones). + pub bit_depth: u8, } /// Decode settings. @@ -50,17 +64,28 @@ impl Default for Options { } } -/// A decoded image: interleaved RGB or RGBA samples, row-major, no padding. 16-bit samples are -/// native-endian `u16`s. +/// How the file describes its colour: the code points of ITU-T H.273, from the `colr` `nclx` +/// box, else the HEVC VUI, else the standard defaults (2 = unspecified, limited range). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Colour { + pub primaries: u16, + pub transfer: u16, + pub matrix: u16, + pub full_range: bool, +} + +/// A decoded image: interleaved RGB or RGBA, 16 bits per sample whatever the coded depth, +/// row-major, no padding. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Decoded { pub width: u32, pub height: u32, /// Four channels (RGBA) instead of three (RGB). pub has_alpha: bool, - /// Two bytes per sample instead of one. - pub sixteen_bit: bool, - pub data: Vec, + /// Bits per sample as coded. + pub bit_depth: u8, + pub data: Vec, + pub colour: Colour, /// The primary image's ICC profile. pub icc: Option>, /// EXIF as a TIFF structure (without HEIF's offset header). Its Orientation tag only mirrors @@ -74,7 +99,7 @@ pub struct Decoded { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Error { /// A valid file using something this decoder does not handle (image sequences, overlays…). - Unsupported(String), + Unsupported(&'static str), /// The image is larger than [`Options::max_pixels`] or holds an oversized box. Limit(String), /// Broken or truncated data (or a decoder bug, reported the same way). @@ -95,11 +120,15 @@ impl std::error::Error for Error {} const SEQUENCE: &str = "this file holds an image sequence; only HEIF still images can be opened"; -fn err(e: heic_rs::Error) -> Error { +/// heic-rs 0.1.1 reconstructs 4:2:0 and monochrome pictures exactly but fails on the 4:2:2 and +/// 4:4:4 ones some cameras write (Canon and Sony "HIF"); those failures are reported as this. +const CHROMA_422_444: &str = "4:2:2 and 4:4:4 HEIF images (as some Canon and Sony cameras write) can't be decoded yet"; + +pub(crate) fn err(e: heic_rs::Error) -> Error { match e { - heic_rs::Error::Unsupported(what) => Error::Unsupported(what.to_string()), + heic_rs::Error::Unsupported(what) => Error::Unsupported(what), // The primary item is neither an HEVC picture nor a grid of them. - heic_rs::Error::MissingBox("hvcC") => Error::Unsupported("the image is an overlay, an identity derivation or not HEVC-coded".into()), + heic_rs::Error::MissingBox("hvcC") => Error::Unsupported("the image is an overlay, an identity derivation or not HEVC-coded"), heic_rs::Error::PixelLimit { .. } | heic_rs::Error::BoxTooLarge { .. } => Error::Limit(e.to_string()), e => Error::Malformed(e.to_string()), } @@ -113,7 +142,8 @@ fn guarded(f: impl FnOnce() -> Result) -> Result { } /// Reads the container's declared size, depth and alpha without decoding pixels, so callers can -/// check their limits first. +/// check their limits first. Also refuses a file cut short: every coded picture of the primary +/// image must lie within `bytes`. pub fn probe(bytes: &[u8]) -> Result { guarded(|| probe_unguarded(bytes)) } @@ -121,16 +151,26 @@ pub fn probe(bytes: &[u8]) -> Result { fn probe_unguarded(bytes: &[u8]) -> Result { let info = heic_rs::probe(bytes).map_err(|e| match e { // No `meta` box: no still image, only a `moov` image sequence (a video track). - heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE.into()), + heic_rs::Error::MissingBox("meta") => Error::Unsupported(SEQUENCE), e => err(e), })?; + let ctx = Context::open(bytes).map_err(err)?; + let id = ctx.meta.primary; + let coded = match ctx.grid(id).map_err(err)? { + Some((_, tiles)) => tiles, + None => vec![id], + }; + for item in coded { + // Borrowed from `bytes` unless split across extents: cheap. + ctx.item_data(item).map_err(err)?; + } Ok(Info { width: info.width, height: info.height, coded_width: info.coded_width, coded_height: info.coded_height, has_alpha: info.has_alpha, - sixteen_bit: info.bit_depth > 8, + bit_depth: info.bit_depth, }) } @@ -139,49 +179,186 @@ pub fn decode(bytes: &[u8], options: &Options) -> Result { guarded(|| decode_unguarded(bytes, options)) } -fn decode_unguarded(bytes: &[u8], options: &Options) -> Result { - let info = probe_unguarded(bytes)?; - let pixel_layout = match (info.has_alpha, info.sixteen_bit) { - (true, true) => PixelLayout::Rgba16, - (true, false) => PixelLayout::Rgba8, - (false, true) => PixelLayout::Rgb16, - (false, false) => PixelLayout::Rgb8, - }; - let raw = heic_rs::DecodeOptions::default() - .with_layout(pixel_layout) - .with_max_pixels(Some(options.max_pixels)) - .with_transforms(options.apply_transforms) - .with_alpha(info.has_alpha); - let decoded = heic_rs::decode(bytes, &raw).map_err(err)?; - let (icc, exif, xmp) = metadata(bytes); - Ok(Decoded { - width: decoded.width, - height: decoded.height, - has_alpha: info.has_alpha, - sixteen_bit: info.sixteen_bit, - data: decoded.data, - icc, - exif, - xmp, +/// Decodes the smallest thumbnail image the file carries whose long edge is at least +/// `min_edge`, if any (iPhones store one of about 320 × 240). Transforms are applied as for the +/// primary image; the metadata (and, when the thumbnail has no profile of its own, the ICC +/// profile) are the primary image's. +pub fn decode_thumbnail(bytes: &[u8], min_edge: u32, options: &Options) -> Result, Error> { + guarded(|| { + let ctx = Context::open(bytes).map_err(err)?; + let primary = ctx.meta.primary; + let mut best: Option<(u32, u32)> = None; + for r in ctx.meta.refs.iter().filter(|r| r.kind == heic_rs::meta::iref::RefKind::Thmb && r.to.contains(&primary)) { + let Ok(p) = ctx.props(r.from) else { continue }; + let Ok((w, h)) = ctx.coded_size(r.from, &p) else { continue }; + let edge = w.max(h); + if edge >= min_edge && best.is_none_or(|(_, e)| edge < e) { + best = Some((r.from, edge)); + } + } + let Some((id, _)) = best else { return Ok(None) }; + let mut decoded = decode_item(&ctx, id, options, false)?; + if decoded.icc.is_none() { + decoded.icc = item_icc(&ctx, primary); + } + (decoded.exif, decoded.xmp) = metadata(&ctx); + Ok(Some(decoded)) }) } -/// The primary image's ICC profile, EXIF (TIFF structure) and XMP packet. Metadata is a -/// courtesy: a broken metadata item never fails a decode whose pixels came out fine. -fn metadata(bytes: &[u8]) -> (Option>, Option>, Option) { - let Ok(ctx) = heic_rs::context::Context::open(bytes) else { - return (None, None, None); - }; +/// An item's ICC profile; a grid whose own properties carry none takes its first tile's (as on +/// some iPhones). +fn item_icc(ctx: &Context<'_>, id: u32) -> Option> { + let p = ctx.props(id).ok()?; + let icc = p.icc.or_else(|| { + let (_, tiles) = ctx.grid(id).ok()??; + ctx.props(*tiles.first()?).ok()?.icc + }); + icc.map(<[u8]>::to_vec) +} + +fn decode_unguarded(bytes: &[u8], options: &Options) -> Result { + // Refuses sequences with a clear message before anything else is read. + probe_unguarded(bytes)?; + let ctx = Context::open(bytes).map_err(err)?; + let mut decoded = decode_item(&ctx, ctx.meta.primary, options, true)?; + let (exif, xmp) = metadata(&ctx); + decoded.exif = exif; + decoded.xmp = xmp; + Ok(decoded) +} + +/// Decodes one image item (a coded picture or a grid of them) to RGB(A), alpha included when +/// `with_alpha` and the item has an alpha auxiliary image. +fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) -> Result { + let p = ctx.props(id).map_err(err)?; + let limit = options.max_pixels; + // The declared sizes are refused before anything is decoded or allocated. + let (cw, ch) = ctx.coded_size(id, &p).map_err(err)?; + heic_rs::image::check_pixels(cw, ch, limit).map_err(err)?; + let (tw, th) = heic_rs::transform::transformed_size(cw, ch, &p.transforms).map_err(err)?; + heic_rs::image::check_pixels(tw, th, limit).map_err(err)?; + + let coded = Coded::decode(ctx, id, &p, limit)?; + let planes = coded.planes()?; + let colour = colour(&p, &coded.first_props); + let signal = ycc::Signal { matrix: colour.matrix, full_range: colour.full_range }; + let alpha_id = if with_alpha { ctx.alpha_item(id).map_err(err)? } else { None }; + let (w, h) = planes.size(); + let mode = if options.apply_transforms && orient::odd_crop(w, h, &p.transforms)? { ycc::Upsampling::Bilinear } else { ycc::Upsampling::Nearest }; + let mut data = ycc::to_rgb16(&planes, signal, alpha_id.is_some(), mode) + .ok_or_else(|| Error::Malformed("the decoded pictures don't cover the image".into()))?; + if let Some(aux) = alpha_id { + let ap = ctx.props(aux).map_err(err)?; + let alpha = Coded::decode(ctx, aux, &ap, limit)?; + let alpha_planes = alpha.planes()?; + if alpha_planes.size() != (w, h) { + return Err(Error::Unsupported("an alpha image of a different size than the picture")); + } + ycc::put_alpha(&alpha_planes, &mut data).ok_or_else(|| Error::Malformed("the alpha image is incomplete".into()))?; + } + let bit_depth = planes.bit_depth(); + let has_alpha = alpha_id.is_some(); + let mut img = orient::Pixels { width: w, height: h, ch: if has_alpha { 4 } else { 3 }, data }; + if options.apply_transforms { + img = orient::apply(img, &p.transforms)?; + } + Ok(Decoded { + width: u32::try_from(img.width).map_err(|_| Error::Limit("image too wide".into()))?, + height: u32::try_from(img.height).map_err(|_| Error::Limit("image too tall".into()))?, + has_alpha, + bit_depth, + data: img.data, + colour, + // A grid whose own properties carry no profile takes its tiles' (as on some iPhones). + icc: p.icc.or(coded.first_props.icc).map(<[u8]>::to_vec), + exif: None, + xmp: None, + }) +} + +/// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. +struct Coded<'a> { + frames: Vec, + grid: Option, + /// The properties of the first coded picture (the item itself, or the first tile): where the + /// HEVC configuration and its VUI live. + first_props: ItemProps<'a>, + /// A failed 4:2:2 / 4:4:4 picture, for the error message. + chroma_format: u8, +} + +impl<'a> Coded<'a> { + fn decode(ctx: &Context<'a>, id: u32, p: &ItemProps<'a>, limit: u64) -> Result, Error> { + let (grid, ids) = match ctx.grid(id).map_err(err)? { + Some((g, tiles)) => (Some(g), tiles), + None => (None, vec![id]), + }; + let first = *ids.first().ok_or_else(|| Error::Malformed("grid lists no tiles".into()))?; + let first_props = if first == id { p.clone() } else { ctx.props(first).map_err(err)? }; + let chroma_format = first_props.hvcc.as_ref().map_or(1, |c| c.chroma_format); + let decode_one = |t: &u32| -> Result { + let tp = ctx.props(*t).map_err(err)?; + let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; + let data = ctx.item_data(*t).map_err(err)?; + let nals = hvcc.split_nals(&data).map_err(err)?; + let params = hvcc.parameter_sets(); + if params.is_empty() { + return Err(Error::Malformed("hvcC carries no parameter sets".into())); + } + let frame = heic_rs::hevc::decode_still(¶ms, &nals).map_err(err)?; + frame.validate().map_err(err)?; + heic_rs::image::check_pixels(frame.width, frame.height, limit).map_err(err)?; + Ok(frame) + }; + #[cfg(not(target_arch = "wasm32"))] + let frames: Result, Error> = { + use rayon::prelude::*; + ids.par_iter().map(decode_one).collect() + }; + #[cfg(target_arch = "wasm32")] + let frames: Result, Error> = ids.iter().map(decode_one).collect(); + let frames = frames.map_err(|e| match e { + Error::Malformed(_) if chroma_format >= 2 => Error::Unsupported(CHROMA_422_444), + e => e, + })?; + Ok(Coded { frames, grid, first_props, chroma_format }) + } + + fn planes(&self) -> Result, Error> { + match (&self.grid, self.frames.as_slice()) { + (Some(g), tiles) => heic_rs::grid::Mosaic::new(g, tiles).map(ycc::Planes::Grid).map_err(err), + (None, [frame]) => Ok(ycc::Planes::Picture(frame)), + (None, _) => Err(Error::Malformed(format!("expected one coded picture (chroma format {})", self.chroma_format))), + } + } +} + +/// The colour description in force: `nclx` on the item (or, for a grid without one, on its +/// first tile), else the VUI of its (first) coded picture, else the H.265 defaults. +fn colour(p: &ItemProps<'_>, coded: &ItemProps<'_>) -> Colour { + if let Some(n) = p.nclx.or(coded.nclx) { + return Colour { primaries: n.primaries, transfer: n.transfer, matrix: n.matrix_code, full_range: n.range == Range::Full }; + } + let s = coded.hvcc.as_ref().and_then(|c| vui::signal(&c.parameter_sets())).unwrap_or_default(); + Colour { primaries: s.primaries.into(), transfer: s.transfer.into(), matrix: s.matrix.into(), full_range: s.full_range } +} + +/// The primary image's EXIF (TIFF structure) and XMP packet. Metadata is a courtesy: a broken +/// metadata item never fails a decode whose pixels came out fine. +fn metadata(ctx: &Context<'_>) -> (Option>, Option) { let id = ctx.meta.primary; - let icc = ctx.props(id).ok().and_then(|p| ctx.icc(&p).map(<[u8]>::to_vec)); let exif = ctx.exif(id).ok().flatten().map(<[u8]>::to_vec); let xmp = ctx.xmp_item(id).and_then(|x| ctx.item_data(x).ok()).and_then(|d| match d { Cow::Borrowed(b) => String::from_utf8(b.to_vec()).ok(), Cow::Owned(b) => String::from_utf8(b).ok(), }); - (icc, exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) + (exif, xmp.map(|x| x.trim_end_matches('\0').to_string())) } +#[cfg(test)] +mod tests_synthetic; + #[cfg(test)] mod tests { use super::*; @@ -217,12 +394,13 @@ mod tests { for bytes in [&b""[..], b"\0\0\0\x18ftypheic\0\0\0\0mif1heic", &[0xFF; 64]] { assert!(probe(bytes).is_err()); assert!(decode(bytes, &Options::default()).is_err()); + assert!(decode_thumbnail(bytes, 1, &Options::default()).is_err()); } } #[test] fn errors_display_their_kind() { - assert!(Error::Unsupported("x".into()).to_string().contains("unsupported")); + assert!(Error::Unsupported("x").to_string().contains("unsupported")); assert!(Error::Limit("x".into()).to_string().contains("limit")); assert!(Error::Malformed("x".into()).to_string().contains("malformed")); } diff --git a/crates/heif/src/orient.rs b/crates/heif/src/orient.rs new file mode 100644 index 000000000..487eb7e22 --- /dev/null +++ b/crates/heif/src/orient.rs @@ -0,0 +1,178 @@ +//! The container's transformative properties (`clap` crop, `irot` rotation, `imir` mirror), +//! applied to interleaved 16-bit pixels in the order the file associates them. +//! +//! `imir` follows libheif, which writes and reads `axis = 0` as a flip about the horizontal axis +//! (top and bottom exchanged) and `axis = 1` as a left-right flip. heic-rs 0.1.1 names the coded +//! values the other way round (`Mirror::LeftRight` is `axis = 0`), so they are swapped here. + +use heic_rs::props::Transform; +use heic_rs::props::simple::{Clap, Mirror, Rotation}; + +use crate::Error; + +/// Interleaved samples, `ch` per pixel, row-major. +pub struct Pixels { + pub width: usize, + pub height: usize, + pub ch: usize, + pub data: Vec, +} + +impl Pixels { + fn remap(&self, width: usize, height: usize, src: impl Fn(usize, usize) -> (usize, usize)) -> Pixels { + let ch = self.ch; + let mut data = vec![0u16; width * height * ch]; + for (y, row) in data.chunks_exact_mut(width * ch).enumerate() { + for (x, px) in row.chunks_exact_mut(ch).enumerate() { + let (sx, sy) = src(x, y); + let i = (sy * self.width + sx) * ch; + if let Some(s) = self.data.get(i..i + ch) { + px.copy_from_slice(s); + } + } + } + Pixels { width, height, ch, data } + } +} + +/// The rectangle `(left, top, width, height)` a `clap` selects from a `w` × `h` image. Its centre +/// is `((w - 1) / 2 + horizOff, (h - 1) / 2 + vertOff)`; a corner that falls between two pixels +/// is rounded as libheif does: left down, top up. +pub fn crop_rect(w: usize, h: usize, c: Clap) -> Result<(usize, usize, usize, usize), Error> { + let bad = || Error::Malformed("clap selects an impossible rectangle".into()); + let size = |(n, d): (u32, u32)| (d != 0).then(|| n / d).ok_or_else(bad); + let (cw, ch) = (size(c.width)? as usize, size(c.height)? as usize); + if cw == 0 || ch == 0 || cw > w || ch > h { + return Err(bad()); + } + // Twice the corner, so the half pixels of odd sizes stay exact. + let twice = |full: usize, cropped: usize, (n, d): (i32, u32)| -> Result { + if d == 0 { + return Err(bad()); + } + Ok((full - cropped) as i64 + (2 * i64::from(n)).div_euclid(i64::from(d))) + }; + let left = twice(w, cw, c.horiz_off)?.div_euclid(2); + let top = (twice(h, ch, c.vert_off)? + 1).div_euclid(2); + let left = usize::try_from(left).map_err(|_| bad())?; + let top = usize::try_from(top).map_err(|_| bad())?; + if left + cw > w || top + ch > h { + return Err(bad()); + } + Ok((left, top, cw, ch)) +} + +/// Whether a `clap` among `transforms` (applied to a `w` × `h` image) starts at an odd column +/// or row. libheif crops such images after upsampling their chroma bilinearly instead of with +/// its default nearest neighbour; [`crate::ycc`] follows it so the pixels match. +pub fn odd_crop(mut w: usize, mut h: usize, transforms: &[Transform]) -> Result { + for t in transforms { + match *t { + Transform::Crop(c) => { + let (left, top, cw, ch) = crop_rect(w, h, c)?; + if left % 2 == 1 || top % 2 == 1 { + return Ok(true); + } + (w, h) = (cw, ch); + } + Transform::Rotate(Rotation::Ccw90 | Rotation::Ccw270) => (w, h) = (h, w), + _ => {} + } + } + Ok(false) +} + +pub fn apply(mut img: Pixels, transforms: &[Transform]) -> Result { + for t in transforms { + let (w, h) = (img.width, img.height); + img = match *t { + Transform::Crop(c) => { + let (left, top, cw, ch) = crop_rect(w, h, c)?; + img.remap(cw, ch, |x, y| (x + left, y + top)) + } + // Counter-clockwise: the right column becomes the top row. + Transform::Rotate(Rotation::Ccw90) => img.remap(h, w, |x, y| (w - 1 - y, x)), + Transform::Rotate(Rotation::Ccw180) => img.remap(w, h, |x, y| (w - 1 - x, h - 1 - y)), + Transform::Rotate(Rotation::Ccw270) => img.remap(h, w, |x, y| (y, h - 1 - x)), + Transform::Rotate(Rotation::None) => img, + // `axis = 0` (heic-rs's `LeftRight`): top and bottom exchanged, as libheif reads it. + Transform::Mirror(Mirror::LeftRight) => img.remap(w, h, |x, y| (x, h - 1 - y)), + // `axis = 1`: left and right exchanged. + Transform::Mirror(Mirror::TopBottom) => img.remap(w, h, |x, y| (w - 1 - x, y)), + }; + } + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 3×2, one channel: 1 2 3 / 4 5 6. + fn img() -> Pixels { + Pixels { width: 3, height: 2, ch: 1, data: vec![1, 2, 3, 4, 5, 6] } + } + + fn run(t: Transform) -> (usize, usize, Vec) { + let p = apply(img(), &[t]).unwrap(); + (p.width, p.height, p.data) + } + + #[test] + fn rotations_are_counter_clockwise() { + assert_eq!(run(Transform::Rotate(Rotation::Ccw90)), (2, 3, vec![3, 6, 2, 5, 1, 4])); + assert_eq!(run(Transform::Rotate(Rotation::Ccw180)), (3, 2, vec![6, 5, 4, 3, 2, 1])); + assert_eq!(run(Transform::Rotate(Rotation::Ccw270)), (2, 3, vec![4, 1, 5, 2, 6, 3])); + assert_eq!(run(Transform::Rotate(Rotation::None)), (3, 2, vec![1, 2, 3, 4, 5, 6])); + } + + #[test] + fn mirrors_follow_libheif() { + // axis 0 (heic-rs `LeftRight`) exchanges top and bottom; axis 1 left and right. + assert_eq!(run(Transform::Mirror(Mirror::LeftRight)), (3, 2, vec![4, 5, 6, 1, 2, 3])); + assert_eq!(run(Transform::Mirror(Mirror::TopBottom)), (3, 2, vec![3, 2, 1, 6, 5, 4])); + } + + fn clap(w: u32, h: u32, ho: i32, vo: i32) -> Clap { + Clap { width: (w, 1), height: (h, 1), horiz_off: (ho, 1), vert_off: (vo, 1) } + } + + #[test] + fn clap_corners_round_like_libheif() { + // Measured against libheif 1.23 on a 512 × 512 picture. + assert_eq!(crop_rect(512, 512, clap(301, 201, 0, 0)).unwrap(), (105, 156, 301, 201)); + assert_eq!(crop_rect(512, 512, clap(300, 200, 0, 0)).unwrap(), (106, 156, 300, 200)); + assert_eq!(crop_rect(512, 512, clap(301, 201, 37, -21)).unwrap(), (142, 135, 301, 201)); + assert_eq!(crop_rect(512, 512, clap(301, 201, -3, 5)).unwrap(), (102, 161, 301, 201)); + assert_eq!(crop_rect(512, 512, clap(300, 201, 0, 0)).unwrap(), (106, 156, 300, 201)); + assert_eq!(crop_rect(1500, 1000, clap(1001, 701, 37, -21)).unwrap(), (286, 129, 1001, 701)); + let p = apply(img(), &[Transform::Crop(clap(2, 1, 1, 0))]).unwrap(); + // The half-pixel top rounds down a row. + assert_eq!((p.width, p.height, p.data), (2, 1, vec![5, 6])); + } + + #[test] + fn impossible_claps_are_errors() { + for c in [ + clap(0, 1, 0, 0), + clap(4, 1, 0, 0), + clap(3, 2, 1, 0), + clap(3, 2, 0, -1), + Clap { width: (1, 0), ..clap(1, 1, 0, 0) }, + Clap { horiz_off: (0, 0), ..clap(1, 1, 0, 0) }, + ] { + assert!(crop_rect(3, 2, c).is_err(), "{c:?}"); + } + assert!(apply(img(), &[Transform::Crop(clap(9, 9, 0, 0))]).is_err()); + } + + #[test] + fn odd_crops_are_detected_through_rotations() { + assert!(!odd_crop(512, 512, &[Transform::Crop(clap(300, 200, 0, 0))]).unwrap()); + assert!(odd_crop(512, 512, &[Transform::Crop(clap(301, 200, 0, 0))]).unwrap()); + // After a quarter turn the crop applies to a 200 × 300 image. + let t = [Transform::Crop(clap(300, 200, 0, 0)), Transform::Rotate(Rotation::Ccw90), Transform::Crop(clap(197, 300, 0, 0))]; + assert!(odd_crop(512, 512, &t).unwrap()); + assert!(!odd_crop(512, 512, &[Transform::Rotate(Rotation::Ccw90)]).unwrap()); + } +} diff --git a/crates/heif/src/testdata.rs b/crates/heif/src/testdata.rs new file mode 100644 index 000000000..595cfa33c --- /dev/null +++ b/crates/heif/src/testdata.rs @@ -0,0 +1,542 @@ +//! Synthetic HEIF files for tests (feature `testdata`): lossless, so every decoded sample is known. +//! +//! Each coded picture is one 32 × 32 coding tree unit stored as PCM samples (H.265 7.3.8.7): +//! the bitstream carries the YCbCr values verbatim, so the decoder's HEVC stage reproduces them +//! exactly and a test checks everything after it (grid composition, colour, transforms, +//! metadata) against values it chose. The few CABAC bins around the PCM data are written with a +//! minimal arithmetic encoder (9.3.4.4). Larger images are grids of such pictures, which is how +//! iPhones store photos too (with 512 × 512 tiles). +//! +//! Not a general HEIF or HEVC writer: 4:2:0 or monochrome, 8 or 10 bits, a CTU per picture. + +/// Side of every coded picture. +pub const TILE: u32 = 32; + +/// YCbCr samples at a luma position (chroma is taken at even positions, 4:2:0). +pub type Sampler<'a> = &'a dyn Fn(u32, u32) -> [u16; 3]; + +/// A property of the primary image. +#[derive(Debug, Clone, PartialEq)] +pub enum Prop { + /// `colr` of type `prof`. + Icc(Vec), + /// `colr` of type `nclx`: primaries, transfer, matrix, full range. + Nclx(u16, u16, u16, bool), + /// `irot`: quarter turns counter-clockwise. + Irot(u8), + /// `imir` with this axis. + Imir(u8), + /// `clap`: width, height, horizontal and vertical offset (whole pixels). + Clap(u32, u32, i32, i32), +} + +/// What [`build`] writes. +pub struct Spec<'a> { + /// Displayed (grid output) size, before transforms. + pub width: u32, + pub height: u32, + pub bit_depth: u8, + pub sample: Sampler<'a>, + /// Properties of the primary image, in association order. + pub props: Vec, + /// EXIF as a TIFF structure (written with HEIF's 4-byte offset header). + pub exif: Option>, + /// An alpha auxiliary image (monochrome, full range). + pub alpha: Option<&'a dyn Fn(u32, u32) -> u16>, + /// A thumbnail item of half the size with these samples (in its own coordinates). + pub thumbnail: Option>, + /// Auxiliary images of these types the decoder must ignore (HDR gain map, depth…). + pub ignored_aux: Vec<&'static str>, +} + +impl<'a> Spec<'a> { + pub fn new(width: u32, height: u32, sample: Sampler<'a>) -> Spec<'a> { + Spec { width, height, bit_depth: 8, sample, props: Vec::new(), exif: None, alpha: None, thumbnail: None, ignored_aux: Vec::new() } + } +} + +#[derive(Default)] +struct Bits { + bytes: Vec, + n: usize, +} + +impl Bits { + fn put(&mut self, v: u32, len: u32) { + for i in (0..len).rev() { + if self.n.is_multiple_of(8) { + self.bytes.push(0); + } + if (v >> i) & 1 == 1 + && let Some(b) = self.bytes.last_mut() + { + *b |= 0x80 >> (self.n % 8); + } + self.n += 1; + } + } + + fn ue(&mut self, v: u32) { + let x = u64::from(v) + 1; + let len = 64 - x.leading_zeros(); + self.put(0, len - 1); + self.put((x & 0xffff_ffff) as u32, len); + } + + fn align_zero(&mut self) { + while !self.n.is_multiple_of(8) { + self.put(0, 1); + } + } + + fn trailing(&mut self) { + self.put(1, 1); + self.align_zero(); + } +} + +/// The CABAC arithmetic encoder of H.265 9.3.4.4, for the bins these pictures need. +struct Cabac { + low: u32, + range: u32, + first: bool, + outstanding: u32, +} + +impl Cabac { + fn new() -> Cabac { + Cabac { low: 0, range: 510, first: true, outstanding: 0 } + } + + fn put_bit(&mut self, out: &mut Bits, b: u32) { + if self.first { + self.first = false; + } else { + out.put(b, 1); + } + while self.outstanding > 0 { + out.put(1 - b, 1); + self.outstanding -= 1; + } + } + + fn renorm(&mut self, out: &mut Bits) { + while self.range < 256 { + if self.low < 256 { + self.put_bit(out, 0); + } else if self.low >= 512 { + self.low -= 512; + self.put_bit(out, 1); + } else { + self.low -= 256; + self.outstanding += 1; + } + self.range <<= 1; + self.low <<= 1; + } + } + + /// `part_mode` = PART_2Nx2N: the most probable symbol of a fresh context (initValue 184 at + /// QP 26 gives pStateIdx 0, valMps 1), so the LPS range is `rangeTabLps[0][3]` = 240. + fn part_mode_2nx2n(&mut self, out: &mut Bits) { + let lps = [128, 176, 208, 240][((self.range >> 6) & 3) as usize]; + self.range -= lps; + self.renorm(out); + } + + /// A terminating bin equal to 1 (`pcm_flag`, `end_of_slice_segment_flag`), with the flush. + fn terminate_one(&mut self, out: &mut Bits) { + self.range -= 2; + self.low += self.range; + self.range = 2; + self.renorm(out); + self.put_bit(out, (self.low >> 9) & 1); + out.put(((self.low >> 7) & 3) | 1, 2); + } +} + +fn nal(kind: u8, rbsp: &[u8]) -> Vec { + let mut out = vec![kind << 1, 1]; + let mut zeros = 0; + for &b in rbsp { + if zeros >= 2 && b <= 3 { + out.push(3); + zeros = 0; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + out.push(b); + } + out +} + +fn profile_idc(bit_depth: u8, mono: bool) -> u32 { + match (bit_depth, mono) { + (_, true) => 4, // range extensions + (8, false) => 1, + _ => 2, + } +} + +fn ptl(w: &mut Bits, idc: u32) { + w.put(0, 2); + w.put(0, 1); + w.put(idc, 5); + w.put(1 << (31 - idc), 32); // compatible with itself + w.put(0b1001, 4); // progressive, frame only + w.put(0, 22); + w.put(0, 21); + w.put(0, 1); + w.put(93, 8); // level 3.1 +} + +fn parameter_sets(bit_depth: u8, mono: bool) -> [Vec; 3] { + let idc = profile_idc(bit_depth, mono); + let bd = u32::from(bit_depth); + let mut v = Bits::default(); + v.put(0, 4); + v.put(3, 2); + v.put(0, 6); + v.put(0, 3); + v.put(1, 1); + v.put(0xffff, 16); + ptl(&mut v, idc); + v.put(0, 1); // vps_sub_layer_ordering_info_present_flag + v.ue(0); + v.ue(0); + v.ue(0); + v.put(0, 6); // vps_max_layer_id + v.ue(0); // vps_num_layer_sets_minus1 + v.put(0, 1); // vps_timing_info_present_flag + v.put(0, 1); // vps_extension_flag + v.trailing(); + + let mut s = Bits::default(); + s.put(0, 4); + s.put(0, 3); + s.put(1, 1); + ptl(&mut s, idc); + s.ue(0); + s.ue(if mono { 0 } else { 1 }); + s.ue(TILE); + s.ue(TILE); + s.put(0, 1); // conformance_window_flag + s.ue(bd - 8); + s.ue(bd - 8); + s.ue(4); + s.put(1, 1); + s.ue(0); + s.ue(0); + s.ue(0); + s.ue(2); // log2_min_luma_coding_block_size_minus3: 32 + s.ue(0); // log2_diff_max_min_luma_coding_block_size: CTB 32 + s.ue(0); // log2_min_luma_transform_block_size_minus2: 4 + s.ue(3); // log2_diff_max_min_luma_transform_block_size: 32 + s.ue(0); + s.ue(0); + s.put(0, 1); // scaling_list_enabled_flag + s.put(0, 1); // amp_enabled_flag + s.put(0, 1); // sample_adaptive_offset_enabled_flag + s.put(1, 1); // pcm_enabled_flag + s.put(bd - 1, 4); // pcm_sample_bit_depth_luma_minus1 + s.put(bd - 1, 4); // pcm_sample_bit_depth_chroma_minus1 + s.ue(2); // log2_min_pcm_luma_coding_block_size_minus3: 32 + s.ue(0); // log2_diff_max_min_pcm_luma_coding_block_size + s.put(1, 1); // pcm_loop_filter_disabled_flag + s.ue(0); // num_short_term_ref_pic_sets + s.put(0, 1); // long_term_ref_pics_present_flag + s.put(0, 1); // sps_temporal_mvp_enabled_flag + s.put(0, 1); // strong_intra_smoothing_enabled_flag + s.put(0, 1); // vui_parameters_present_flag + s.put(0, 1); // sps_extension_present_flag + s.trailing(); + + let mut p = Bits::default(); + p.ue(0); + p.ue(0); + p.put(0, 6); // dependent slices, output flag, 3 extra header bits, sign hiding + p.put(0, 1); // cabac_init_present_flag + p.ue(0); + p.ue(0); + p.ue(0); // init_qp_minus26 (se(0) is the same bits) + p.put(0, 3); // constrained intra, transform skip, cu_qp_delta + p.ue(0); + p.ue(0); + p.put(0, 1); + p.put(0, 2); + p.put(0, 1); // transquant_bypass_enabled_flag + p.put(0, 1); // tiles_enabled_flag + p.put(0, 1); // entropy_coding_sync_enabled_flag + p.put(0, 1); // pps_loop_filter_across_slices_enabled_flag + p.put(1, 1); // deblocking_filter_control_present_flag + p.put(0, 1); // deblocking_filter_override_enabled_flag + p.put(1, 1); // pps_deblocking_filter_disabled_flag + p.put(0, 1); + p.put(0, 1); + p.ue(0); + p.put(0, 1); + p.put(0, 1); + p.trailing(); + [nal(32, &v.bytes), nal(33, &s.bytes), nal(34, &p.bytes)] +} + +/// One coded 32 × 32 picture: the `hvcC` payload and the item data (length-prefixed NAL). +struct Coded { + hvcc: Vec, + data: Vec, +} + +fn picture(bit_depth: u8, mono: bool, sample: &dyn Fn(u32, u32) -> [u16; 3]) -> Coded { + let bd = u32::from(bit_depth); + let mut w = Bits::default(); + w.put(1, 1); // first_slice_segment_in_pic_flag + w.put(0, 1); // no_output_of_prior_pics_flag + w.ue(0); + w.ue(2); // I slice + w.ue(0); // slice_qp_delta, se(0) + w.trailing(); // byte_alignment() + let mut c = Cabac::new(); + c.part_mode_2nx2n(&mut w); + c.terminate_one(&mut w); // pcm_flag + w.align_zero(); // pcm_alignment_zero_bit + for y in 0..TILE { + for x in 0..TILE { + w.put(u32::from(sample(x, y)[0]), bd); + } + } + if !mono { + for k in 1..3 { + for y in 0..TILE / 2 { + for x in 0..TILE / 2 { + w.put(u32::from(sample(2 * x, 2 * y)[k]), bd); + } + } + } + } + let mut c = Cabac::new(); + c.terminate_one(&mut w); // end_of_slice_segment_flag (its flush ends in the stop bit) + w.align_zero(); + let slice = nal(19, &w.bytes); + + let sets = parameter_sets(bit_depth, mono); + let mut h = vec![1u8, profile_idc(bit_depth, mono) as u8]; + h.extend_from_slice(&(1u32 << (31 - profile_idc(bit_depth, mono))).to_be_bytes()); + h.extend_from_slice(&[ + 0x90, + 0, + 0, + 0, + 0, + 0, + 93, + 0xf0, + 0, + 0xfc, + 0xfc | u8::from(!mono), + 0xf8 | (bit_depth - 8), + 0xf8 | (bit_depth - 8), + 0, + 0, + 0x0f, + 3, + ]); + for (kind, set) in [32u8, 33, 34].into_iter().zip(&sets) { + h.push(0x80 | kind); + h.extend_from_slice(&1u16.to_be_bytes()); + h.extend_from_slice(&(set.len() as u16).to_be_bytes()); + h.extend_from_slice(set); + } + let mut data = (slice.len() as u32).to_be_bytes().to_vec(); + data.extend_from_slice(&slice); + Coded { hvcc: h, data } +} + +fn bx(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut v = ((payload.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(payload); + v +} + +fn full_box(kind: &[u8; 4], version: u8, payload: &[u8]) -> Vec { + let mut p = vec![version, 0, 0, 0]; + p.extend_from_slice(payload); + bx(kind, &p) +} + +struct Item { + id: u16, + kind: &'static [u8; 4], + data: Vec, + /// Indices (1-based) into the property list, with the essential flag. + props: Vec<(u8, bool)>, + /// Not an image of its own (a grid tile). + hidden: bool, +} + +#[derive(Default)] +struct File { + items: Vec, + props: Vec>, + refs: Vec<(&'static [u8; 4], u16, Vec)>, +} + +impl File { + fn prop(&mut self, b: Vec) -> u8 { + self.props.push(b); + self.props.len() as u8 + } + + fn add(&mut self, kind: &'static [u8; 4], data: Vec, props: Vec<(u8, bool)>) -> u16 { + let id = self.items.len() as u16 + 1; + self.items.push(Item { id, kind, data, props, hidden: false }); + id + } + + /// A coded image of `w` × `h` (a grid when larger than a tile); returns its item id. + fn image(&mut self, w: u32, h: u32, bit_depth: u8, mono: bool, sample: &dyn Fn(u32, u32) -> [u16; 3]) -> u16 { + let (cols, rows) = (w.div_ceil(TILE), h.div_ceil(TILE)); + let ispe = |w: u32, h: u32| { + let mut p = w.to_be_bytes().to_vec(); + p.extend_from_slice(&h.to_be_bytes()); + full_box(b"ispe", 0, &p) + }; + let mut hvcc_prop = None; + let tile_ispe = self.prop(ispe(TILE, TILE)); + let mut tiles = Vec::new(); + for r in 0..rows { + for c in 0..cols { + let coded = picture(bit_depth, mono, &|x, y| sample((c * TILE + x).min(w - 1), (r * TILE + y).min(h - 1))); + let hv = *hvcc_prop.get_or_insert_with(|| self.prop(bx(b"hvcC", &coded.hvcc))); + tiles.push(self.add(b"hvc1", coded.data, vec![(hv, true), (tile_ispe, false)])); + } + } + if let ([only], true) = (tiles.as_slice(), w == TILE && h == TILE) { + return *only; + } + for it in self.items.iter_mut().filter(|i| tiles.contains(&i.id)) { + it.hidden = true; + } + let mut g = vec![0u8, 0, (rows - 1) as u8, (cols - 1) as u8]; + g.extend_from_slice(&(w as u16).to_be_bytes()); + g.extend_from_slice(&(h as u16).to_be_bytes()); + let grid_ispe = self.prop(ispe(w, h)); + let id = self.add(b"grid", g, vec![(grid_ispe, false)]); + self.refs.push((b"dimg", id, tiles)); + id + } + + fn write(self, primary: u16) -> Vec { + let ftyp = bx(b"ftyp", b"heic\0\0\0\0mif1heic"); + let mut hdlr = vec![0u8; 4]; + hdlr.extend_from_slice(b"pict"); + hdlr.extend_from_slice(&[0; 13]); + let hdlr = full_box(b"hdlr", 0, &hdlr); + let pitm = full_box(b"pitm", 0, &primary.to_be_bytes()); + let mut iinf = (self.items.len() as u16).to_be_bytes().to_vec(); + for it in &self.items { + let mut e = vec![2, 0, 0, u8::from(it.hidden)]; // version 2, flags + e.extend_from_slice(&it.id.to_be_bytes()); + e.extend_from_slice(&[0, 0]); + e.extend_from_slice(it.kind); + e.push(0); + iinf.extend_from_slice(&bx(b"infe", &e)); + } + let iinf = full_box(b"iinf", 0, &iinf); + let mut iref = Vec::new(); + for (kind, from, to) in &self.refs { + let mut r = from.to_be_bytes().to_vec(); + r.extend_from_slice(&(to.len() as u16).to_be_bytes()); + for t in to { + r.extend_from_slice(&t.to_be_bytes()); + } + iref.extend_from_slice(&bx(kind, &r)); + } + let iref = full_box(b"iref", 0, &iref); + let ipco = bx(b"ipco", &self.props.concat()); + let mut ipma = (self.items.iter().filter(|i| !i.props.is_empty()).count() as u32).to_be_bytes().to_vec(); + for it in self.items.iter().filter(|i| !i.props.is_empty()) { + ipma.extend_from_slice(&it.id.to_be_bytes()); + ipma.push(it.props.len() as u8); + ipma.extend(it.props.iter().map(|&(i, essential)| i | if essential { 0x80 } else { 0 })); + } + let iprp = bx(b"iprp", &[ipco, full_box(b"ipma", 0, &ipma)].concat()); + let iloc = |base: u32| { + let mut l = vec![0x44, 0x00]; + l.extend_from_slice(&(self.items.len() as u16).to_be_bytes()); + let mut offset = base; + for it in &self.items { + l.extend_from_slice(&it.id.to_be_bytes()); + l.extend_from_slice(&[0, 0, 0, 0, 0, 1]); // construction method 0, data ref 0, one extent + l.extend_from_slice(&offset.to_be_bytes()); + l.extend_from_slice(&(it.data.len() as u32).to_be_bytes()); + offset += it.data.len() as u32; + } + full_box(b"iloc", 1, &l) + }; + let meta = |base| full_box(b"meta", 0, &[hdlr.clone(), pitm.clone(), iloc(base), iinf.clone(), iref.clone(), iprp.clone()].concat()); + let start = (ftyp.len() + meta(0).len() + 8) as u32; + let mdat = bx(b"mdat", &self.items.iter().flat_map(|i| i.data.iter().copied()).collect::>()); + [ftyp, meta(start), mdat].concat() + } +} + +/// Writes the HEIF file `spec` describes. +pub fn build(spec: &Spec<'_>) -> Vec { + let mut f = File::default(); + let primary = f.image(spec.width, spec.height, spec.bit_depth, false, spec.sample); + let mut assoc = Vec::new(); + for p in &spec.props { + let b = match p { + Prop::Icc(icc) => bx(b"colr", &[b"prof".as_slice(), icc].concat()), + Prop::Nclx(pr, tc, mc, full) => { + let mut c = b"nclx".to_vec(); + for v in [pr, tc, mc] { + c.extend_from_slice(&v.to_be_bytes()); + } + c.push(if *full { 0x80 } else { 0 }); + bx(b"colr", &c) + } + Prop::Irot(a) => bx(b"irot", &[a & 3]), + Prop::Imir(a) => bx(b"imir", &[a & 1]), + Prop::Clap(w, h, ho, vo) => { + let mut c = Vec::new(); + for v in [*w as i32, 1, *h as i32, 1, *ho, 1, *vo, 1] { + c.extend_from_slice(&v.to_be_bytes()); + } + bx(b"clap", &c) + } + }; + assoc.push((f.prop(b), true, matches!(p, Prop::Clap(..)))); + } + if let Some(it) = f.items.iter_mut().find(|i| i.id == primary) { + it.props.extend(assoc.iter().map(|&(i, e, _)| (i, e))); + } + if let Some(exif) = &spec.exif { + let id = f.add(b"Exif", [&[0u8, 0, 0, 0][..], exif].concat(), Vec::new()); + f.refs.push((b"cdsc", id, vec![primary])); + } + let bd = spec.bit_depth; + let aux = |f: &mut File, urn: &str, sample: &dyn Fn(u32, u32) -> [u16; 3]| { + let id = f.image(spec.width, spec.height, bd, true, sample); + let auxc = f.prop(full_box(b"auxC", 0, &[urn.as_bytes(), &[0]].concat())); + if let Some(it) = f.items.iter_mut().find(|i| i.id == id) { + it.props.push((auxc, true)); + } + f.refs.push((b"auxl", id, vec![primary])); + }; + if let Some(alpha) = spec.alpha { + aux(&mut f, "urn:mpeg:hevc:2015:auxid:1", &|x, y| [alpha(x, y), 0, 0]); + } + for urn in &spec.ignored_aux { + aux(&mut f, urn, &|x, y| [((x + y) % 256) as u16, 0, 0]); + } + if let Some(sample) = spec.thumbnail { + // Half the size, with the primary image's colour and orientation (not its crop). + let id = f.image(spec.width / 2, spec.height / 2, spec.bit_depth, false, sample); + if let Some(it) = f.items.iter_mut().find(|i| i.id == id) { + it.props.extend(assoc.iter().filter(|a| !a.2).map(|&(i, e, _)| (i, e))); + } + f.refs.push((b"thmb", id, vec![primary])); + } + f.write(primary) +} diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs new file mode 100644 index 000000000..e229af926 --- /dev/null +++ b/crates/heif/src/tests_synthetic.rs @@ -0,0 +1,176 @@ +//! Decoding lossless synthetic HEIF files ([`crate::testdata`]): every expected sample is known. + +use crate::testdata::{Prop, Spec, build}; +use crate::{Error, Options, decode, decode_thumbnail, probe}; + +/// A luma value unique enough to identify its position; chroma neutral. +fn luma(x: u32, y: u32) -> u16 { + ((x * 7 + y * 13) % 220 + 20) as u16 +} + +fn grey(x: u32, y: u32) -> [u16; 3] { + [luma(x, y), 128, 128] +} + +const FULL_601: Prop = Prop::Nclx(1, 13, 6, true); + +/// Decoded red channel at `(x, y)`, back in 8-bit code values. +fn at(d: &crate::Decoded, x: u32, y: u32) -> u16 { + let ch = if d.has_alpha { 4 } else { 3 }; + d.data[((y * d.width + x) as usize) * ch] / 257 +} + +fn decoded(spec: &Spec) -> crate::Decoded { + decode(&build(spec), &Options::default()).unwrap() +} + +#[test] +fn a_grid_reassembles_exactly() { + // 4 × 3 tiles of 32, cropped by the grid to 100 × 70. + let d = decoded(&Spec { props: vec![FULL_601], ..Spec::new(100, 70, &grey) }); + assert_eq!((d.width, d.height, d.bit_depth, d.has_alpha), (100, 70, 8, false)); + for y in 0..70 { + for x in 0..100 { + let px = &d.data[((y * 100 + x) * 3) as usize..][..3]; + assert_eq!(px, [luma(x, y) * 257; 3], "({x}, {y})"); + } + } + let info = probe(&build(&Spec::new(100, 70, &grey))).unwrap(); + assert_eq!((info.width, info.height, info.coded_width, info.coded_height, info.bit_depth), (100, 70, 100, 70, 8)); +} + +#[test] +fn colour_follows_the_matrix_and_range() { + // BT.601 full range, a saturated orange: within one code value of the exact conversion. + let (y, cb, cr) = (150.0f32, 80.0, 190.0); + let s = |_: u32, _: u32| [y as u16, cb as u16, cr as u16]; + let d = decoded(&Spec { props: vec![FULL_601], ..Spec::new(32, 32, &s) }); + let (u, v) = (cb - 128.0, cr - 128.0); + let expected = [y + 1.402 * v, y - 0.344_136 * u - 0.714_136 * v, y + 1.772 * u].map(|c| c.clamp(0.0, 255.0)); + for (got, want) in d.data[..3].iter().zip(expected) { + assert!((f32::from(*got) / 257.0 - want).abs() <= 1.0, "{:?} vs {expected:?}", &d.data[..3]); + } + assert_eq!(d.colour, crate::Colour { primaries: 1, transfer: 13, matrix: 6, full_range: true }); + + // No nclx and no VUI: the H.265 defaults, limited range (16 → black, 235 → white). + let ends = |x: u32, _: u32| [if x < 16 { 16 } else { 235 }, 128, 128]; + let d = decoded(&Spec::new(32, 32, &ends)); + assert!(!d.colour.full_range); + assert_eq!((d.data[0], d.data[20 * 3]), (0, 65535)); +} + +#[test] +fn ten_bit_samples_keep_their_precision() { + let s = |x: u32, y: u32| [(x * 31 + y) as u16 % 1024, 512, 512]; + let d = decoded(&Spec { bit_depth: 10, props: vec![FULL_601], ..Spec::new(64, 32, &s) }); + assert_eq!(d.bit_depth, 10); + for x in [0, 1, 5, 33, 63] { + let want = ((x * 31) % 1024) as f64 / 1023.0 * 65535.0; + assert!((f64::from(d.data[(x * 3) as usize]) - want).abs() <= 1.0, "x {x}"); + } +} + +#[test] +fn container_transforms_are_applied_like_libheif() { + let (w, h) = (70u32, 40u32); + let spec = |props: Vec| Spec { props: [vec![FULL_601], props].concat(), ..Spec::new(w, h, &grey) }; + // irot: counter-clockwise quarter turns. + let d = decoded(&spec(vec![Prop::Irot(1)])); + assert_eq!((d.width, d.height), (h, w)); + assert_eq!(at(&d, 0, 0), luma(w - 1, 0)); // the right column becomes the top row + assert_eq!(at(&d, h - 1, w - 1), luma(0, h - 1)); + let d = decoded(&spec(vec![Prop::Irot(2)])); + assert_eq!(at(&d, 0, 0), luma(w - 1, h - 1)); + let d = decoded(&spec(vec![Prop::Irot(3)])); + assert_eq!(at(&d, 0, 0), luma(0, h - 1)); + // imir: axis 0 exchanges top and bottom, axis 1 left and right (libheif's reading). + let d = decoded(&spec(vec![Prop::Imir(0)])); + assert_eq!((at(&d, 0, 0), at(&d, 5, 0)), (luma(0, h - 1), luma(5, h - 1))); + let d = decoded(&spec(vec![Prop::Imir(1)])); + assert_eq!((at(&d, 0, 0), at(&d, 0, 5)), (luma(w - 1, 0), luma(w - 1, 5))); + // clap: centred crop moved by its offsets; a half-pixel corner goes left and down. + let d = decoded(&spec(vec![Prop::Clap(31, 21, 3, -2)])); + assert_eq!((d.width, d.height), (31, 21)); + // left = (70 - 31 + 6) / 2 = 22.5 → 22, top = (40 - 21 - 4) / 2 = 7.5 → 8 + assert_eq!(at(&d, 0, 0), luma(22, 8)); + // In association order: crop, then turn, then mirror. + let d = decoded(&spec(vec![Prop::Clap(30, 20, 0, 0), Prop::Irot(1), Prop::Imir(1)])); + assert_eq!((d.width, d.height), (20, 30)); + // The crop is (20, 10) to (49, 29); the turn brings its bottom-right corner to the top-right, + // the left-right mirror to the top-left. + assert_eq!(at(&d, 0, 0), luma(49, 29)); + let info = probe(&build(&spec(vec![Prop::Irot(1)]))).unwrap(); + assert_eq!((info.width, info.height, info.coded_width, info.coded_height), (h, w, w, h)); + // Transforms off: as coded. + let d = decode(&build(&spec(vec![Prop::Irot(1)])), &Options { apply_transforms: false, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height, at(&d, 3, 2)), (w, h, luma(3, 2))); +} + +#[test] +fn metadata_aux_images_and_thumbnails() { + let icc = b"not parsed here: carried verbatim".to_vec(); + let exif = b"MM\0\x2a\0\0\0\x08\0\0".to_vec(); + let spec = Spec { + props: vec![Prop::Icc(icc.clone()), FULL_601], + exif: Some(exif.clone()), + thumbnail: Some(&|_, _| [77, 128, 128]), + ignored_aux: vec!["urn:com:apple:photo:2020:aux:hdrgainmap", "urn:mpeg:hevc:2015:auxid:2"], + ..Spec::new(64, 64, &grey) + }; + let bytes = build(&spec); + let d = decode(&bytes, &Options::default()).unwrap(); + assert_eq!((d.width, d.height, d.has_alpha), (64, 64, false)); + assert_eq!(d.icc.as_deref(), Some(icc.as_slice())); + assert_eq!(d.exif.as_deref(), Some(exif.as_slice())); + let t = decode_thumbnail(&bytes, 32, &Options::default()).unwrap().unwrap(); + assert_eq!((t.width, t.height, t.data[0]), (32, 32, 77 * 257)); + assert_eq!(t.exif.as_deref(), Some(exif.as_slice())); + assert_eq!(t.icc.as_deref(), Some(icc.as_slice())); + assert!(decode_thumbnail(&bytes, 33, &Options::default()).unwrap().is_none()); +} + +#[test] +fn alpha_comes_from_its_auxiliary_image() { + let alpha = |x: u32, _: u32| (x * 4) as u16; + let d = decoded(&Spec { props: vec![FULL_601], alpha: Some(&alpha), ..Spec::new(64, 32, &grey) }); + assert!(d.has_alpha); + assert_eq!(d.data.len(), 64 * 32 * 4); + assert_eq!((d.data[3], d.data[10 * 4 + 3]), (0, 40 * 257)); + assert_eq!(d.data[..3], [luma(0, 0) * 257; 3]); +} + +#[test] +fn limits_are_checked_before_decoding() { + let bytes = build(&Spec::new(96, 64, &grey)); + let r = decode(&bytes, &Options { max_pixels: 96 * 64 - 1, ..Default::default() }); + assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); +} + +#[test] +fn truncated_and_corrupted_files_are_errors_not_panics() { + let bytes = build(&Spec { props: vec![FULL_601, Prop::Irot(1)], thumbnail: Some(&grey), ..Spec::new(64, 64, &grey) }); + let full = decode(&bytes, &Options::default()).unwrap(); + for cut in (0..bytes.len()).step_by(7) { + let b = &bytes[..cut]; + // Cut inside the photo: an error. Cut in the trailing thumbnail: the whole photo. + if let (Ok(_), Ok(d)) = (probe(b), decode(b, &Options::default())) { + assert_eq!(d.data, full.data, "cut at {cut} of {}", bytes.len()); + } + let _ = decode_thumbnail(b, 1, &Options::default()); + } + // Flip bits all over the file: any outcome but a panic. + let mut state = 0x2545_f491_u32; + for _ in 0..400 { + let mut b = bytes.clone(); + for _ in 0..3 { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let i = state as usize % b.len(); + b[i] ^= 1 << (state % 8); + } + let _ = probe(&b); + let _ = decode(&b, &Options::default()); + let _ = decode_thumbnail(&b, 1, &Options::default()); + } +} diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs new file mode 100644 index 000000000..d732f2863 --- /dev/null +++ b/crates/heif/src/vui.rs @@ -0,0 +1,427 @@ +//! The colour description in an HEVC sequence parameter set's VUI (Rec. ITU-T H.265, 7.3.2.2 and +//! Annex E): the fallback a HEIF reader uses when the item carries no `colr` box of type `nclx`. +//! iPhone photos are the common case: they label their colour with an ICC profile only and say in +//! the VUI that their YCbCr is full range. +//! +//! Only the syntax up to `matrix_coeffs` is read. Anything this parser can't follow returns `None` +//! and the caller falls back to the H.265 defaults; it never fails a decode. + +/// What the VUI's `video_signal_type` says (H.265 E.3.1). Absent fields keep the defaults the +/// standard infers: limited range, every code point 2 ("unspecified"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Signal { + pub full_range: bool, + pub primaries: u8, + pub transfer: u8, + pub matrix: u8, +} + +impl Default for Signal { + fn default() -> Self { + Signal { full_range: false, primaries: 2, transfer: 2, matrix: 2 } + } +} + +/// NAL unit type of a sequence parameter set. +const SPS_NUT: u8 = 33; + +/// Reads the signal description from the SPS among `parameter_sets` (NAL units with their +/// two-byte header, as an `hvcC` record stores them). `None`: no SPS, or one this reader can't +/// follow; `Some(Signal::default())`: an SPS without VUI colour fields. +pub fn signal(parameter_sets: &[&[u8]]) -> Option { + let sps = parameter_sets.iter().find(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT))?; + parse_sps(&unescape(sps.get(2..)?)) +} + +/// Removes emulation prevention bytes (`00 00 03` → `00 00`). +fn unescape(nal: &[u8]) -> Vec { + let mut out = Vec::with_capacity(nal.len()); + let mut zeros = 0usize; + for &b in nal { + if zeros >= 2 && b == 3 { + zeros = 0; + continue; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + out.push(b); + } + out +} + +struct Bits<'a> { + data: &'a [u8], + pos: usize, +} + +impl Bits<'_> { + fn u(&mut self, n: u32) -> Option { + let mut v = 0u32; + for _ in 0..n { + let byte = *self.data.get(self.pos / 8)?; + v = (v << 1) | u32::from((byte >> (7 - self.pos % 8)) & 1); + self.pos += 1; + } + Some(v) + } + + fn flag(&mut self) -> Option { + self.u(1).map(|b| b == 1) + } + + fn skip(&mut self, n: usize) -> Option<()> { + self.pos = self.pos.checked_add(n)?; + (self.pos <= self.data.len().saturating_mul(8)).then_some(()) + } + + /// `ue(v)`; values past 32 bits are refused (no valid field here needs them). + fn ue(&mut self) -> Option { + let mut zeros = 0u32; + while !self.flag()? { + zeros += 1; + if zeros > 31 { + return None; + } + } + let rest = self.u(zeros)?; + ((1u64 << zeros) - 1 + u64::from(rest)).try_into().ok() + } + + fn se(&mut self) -> Option<()> { + self.ue().map(|_| ()) + } +} + +/// `profile_tier_level(1, max_sub_layers_minus1)` (7.3.3): only skipped. +fn profile_tier_level(r: &mut Bits, max_sub_layers_minus1: u32) -> Option<()> { + r.skip(88)?; // general profile space .. general_inbld/reserved flag + r.skip(8)?; // general_level_idc + let mut present = [(false, false); 8]; + for p in present.iter_mut().take(max_sub_layers_minus1 as usize) { + *p = (r.flag()?, r.flag()?); + } + if max_sub_layers_minus1 > 0 { + r.skip(2 * (8 - max_sub_layers_minus1 as usize))?; // reserved_zero_2bits + } + for &(profile, level) in present.iter().take(max_sub_layers_minus1 as usize) { + if profile { + r.skip(88)?; + } + if level { + r.skip(8)?; + } + } + Some(()) +} + +/// `scaling_list_data()` (7.3.4): only skipped. +fn scaling_list_data(r: &mut Bits) -> Option<()> { + for size_id in 0..4u32 { + let step = if size_id == 3 { 3 } else { 1 }; + for _ in (0..6).step_by(step) { + if !r.flag()? { + r.ue()?; // scaling_list_pred_matrix_id_delta + } else { + let coefs = 64.min(1 << (4 + (size_id << 1))); + if size_id > 1 { + r.se()?; // scaling_list_dc_coef_minus8 + } + for _ in 0..coefs { + r.se()?; // scaling_list_delta_coef + } + } + } + } + Some(()) +} + +/// `st_ref_pic_set(idx)` (7.3.7) as it appears in an SPS; returns `NumDeltaPocs[idx]`. +fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option { + if idx != 0 && r.flag()? { + // inter_ref_pic_set_prediction_flag: predicted from the previous set (in an SPS, + // delta_idx_minus1 is absent and RefRpsIdx = idx - 1). + r.skip(1)?; // delta_rps_sign + r.ue()?; // abs_delta_rps_minus1 + let reference = *num_delta_pocs.get(idx - 1)?; + let mut count = 0u32; + for _ in 0..=reference { + let used = r.flag()?; + let kept = used || r.flag()?; // use_delta_flag when not used_by_curr_pic_flag + count += u32::from(kept); + } + return Some(count); + } + let negative = r.ue()?; + let positive = r.ue()?; + let total = negative.checked_add(positive)?; + if total > 32 { + return None; // at most 16 + 16 for any level + } + for _ in 0..total { + r.ue()?; // delta_poc_s*_minus1 + r.skip(1)?; // used_by_curr_pic_s*_flag + } + Some(total) +} + +fn parse_sps(rbsp: &[u8]) -> Option { + let r = &mut Bits { data: rbsp, pos: 0 }; + r.skip(4)?; // sps_video_parameter_set_id + let max_sub_layers_minus1 = r.u(3)?; + if max_sub_layers_minus1 > 6 { + return None; + } + r.skip(1)?; // sps_temporal_id_nesting_flag + profile_tier_level(r, max_sub_layers_minus1)?; + r.ue()?; // sps_seq_parameter_set_id + if r.ue()? == 3 { + r.skip(1)?; // separate_colour_plane_flag + } + r.ue()?; // pic_width_in_luma_samples + r.ue()?; // pic_height_in_luma_samples + if r.flag()? { + for _ in 0..4 { + r.ue()?; // conf_win_*_offset + } + } + r.ue()?; // bit_depth_luma_minus8 + r.ue()?; // bit_depth_chroma_minus8 + let log2_max_poc_lsb = r.ue()?.checked_add(4)?; + if log2_max_poc_lsb > 16 { + return None; + } + let first = if r.flag()? { 0 } else { max_sub_layers_minus1 }; + for _ in first..=max_sub_layers_minus1 { + r.ue()?; // sps_max_dec_pic_buffering_minus1 + r.ue()?; // sps_max_num_reorder_pics + r.ue()?; // sps_max_latency_increase_plus1 + } + for _ in 0..6 { + r.ue()?; // coding/transform block sizes, transform hierarchy depths + } + if r.flag()? && r.flag()? { + // scaling_list_enabled_flag, sps_scaling_list_data_present_flag + scaling_list_data(r)?; + } + r.skip(2)?; // amp_enabled_flag, sample_adaptive_offset_enabled_flag + if r.flag()? { + // pcm_enabled_flag + r.skip(8)?; // pcm sample bit depths + r.ue()?; + r.ue()?; + r.skip(1)?; // pcm_loop_filter_disabled_flag + } + let sets = r.ue()? as usize; + if sets > 64 { + return None; + } + let mut num_delta_pocs = Vec::with_capacity(sets); + for idx in 0..sets { + let n = st_ref_pic_set(r, idx, &num_delta_pocs)?; + num_delta_pocs.push(n); + } + if r.flag()? { + // long_term_ref_pics_present_flag + let n = r.ue()?; + if n > 32 { + return None; + } + for _ in 0..n { + r.skip(log2_max_poc_lsb as usize + 1)?; // lt_ref_pic_poc_lsb_sps, used_by_curr_pic_lt_sps_flag + } + } + r.skip(2)?; // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag + let mut signal = Signal::default(); + if !r.flag()? { + return Some(signal); // no VUI + } + if r.flag()? && r.u(8)? == 255 { + // aspect_ratio_info_present_flag, aspect_ratio_idc == EXTENDED_SAR + r.skip(32)?; + } + if r.flag()? { + r.skip(1)?; // overscan_appropriate_flag + } + if r.flag()? { + // video_signal_type_present_flag + r.skip(3)?; // video_format + signal.full_range = r.flag()?; + if r.flag()? { + signal.primaries = r.u(8)? as u8; + signal.transfer = r.u(8)? as u8; + signal.matrix = r.u(8)? as u8; + } + } + Some(signal) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A bit writer for building parameter sets in tests. + #[derive(Default)] + pub(crate) struct Writer { + pub bits: Vec, + } + + impl Writer { + pub fn u(&mut self, v: u32, n: u32) { + for i in (0..n).rev() { + self.bits.push(i < 32 && (v >> i) & 1 == 1); + } + } + pub fn ue(&mut self, v: u32) { + let x = u64::from(v) + 1; + let len = 64 - x.leading_zeros(); + self.u(0, len - 1); + for i in (0..len).rev() { + self.bits.push((x >> i) & 1 == 1); + } + } + pub fn bytes(mut self) -> Vec { + self.bits.push(true); // rbsp_stop_one_bit + while !self.bits.len().is_multiple_of(8) { + self.bits.push(false); + } + self.bits.chunks(8).map(|c| c.iter().fold(0u8, |a, &b| (a << 1) | u8::from(b))).collect() + } + } + + fn sps(vui: impl FnOnce(&mut Writer), st_sets: impl FnOnce(&mut Writer)) -> Vec { + let mut w = Writer::default(); + w.u(0, 4); + w.u(1, 3); // one sub-layer above the base + w.u(1, 1); + w.u(0, 88); + w.u(93, 8); + w.u(0b11, 2); // sub-layer profile and level present + w.u(0, 2 * 7); + w.u(0, 88); + w.u(0, 8); + w.ue(0); + w.ue(1); // 4:2:0 + w.ue(4032); + w.ue(3024); + w.u(1, 1); // conformance window + for _ in 0..4 { + w.ue(3); + } + w.ue(0); + w.ue(0); + w.ue(4); + w.u(1, 1); + for _ in 0..2 { + w.ue(1); + w.ue(0); + w.ue(0); + } + for v in [0, 3, 0, 3, 0, 0] { + w.ue(v); + } + w.u(1, 1); // scaling lists, with data + w.u(1, 1); + for size_id in 0..4u32 { + for _ in (0..6).step_by(if size_id == 3 { 3 } else { 1 }) { + w.u(1, 1); + if size_id > 1 { + w.ue(0); + } + for _ in 0..64.min(1 << (4 + (size_id << 1))) { + w.ue(0); + } + } + } + w.u(0, 2); + w.u(1, 1); // pcm + w.u(0x77, 8); + w.ue(0); + w.ue(1); + w.u(1, 1); + st_sets(&mut w); + w.u(1, 1); // long-term pictures + w.ue(1); + w.u(0, 8 + 1); + w.u(0, 2); + vui(&mut w); + let mut nal = vec![SPS_NUT << 1, 1]; + // escape like a real NAL so `signal` has to unescape + let mut zeros = 0; + for b in w.bytes() { + if zeros >= 2 && b <= 3 { + nal.push(3); + zeros = 0; + } + zeros = if b == 0 { zeros + 1 } else { 0 }; + nal.push(b); + } + nal + } + + fn two_rps(w: &mut Writer) { + w.ue(2); + w.ue(1); // set 0: one negative picture + w.ue(0); + w.ue(0); + w.u(1, 1); + w.u(1, 1); // set 1: predicted from set 0 + w.u(0, 1); + w.ue(0); + w.u(0, 1); // not used ... + w.u(1, 1); // ... but kept + w.u(1, 1); + } + + #[test] + fn reads_full_range_and_matrix_from_the_vui() { + let nal = sps( + |w| { + w.u(1, 1); // vui present + w.u(1, 1); + w.u(255, 8); + w.u(0x0001_0001, 32); + w.u(1, 1); + w.u(0, 1); + w.u(1, 1); // video_signal_type_present_flag + w.u(5, 3); + w.u(1, 1); // full range + w.u(1, 1); + w.u(12, 8); + w.u(13, 8); + w.u(6, 8); + }, + two_rps, + ); + let s = signal(&[&[0x40, 1, 0xc], &nal, &[0x44, 1]]); + assert_eq!(s, Some(Signal { full_range: true, primaries: 12, transfer: 13, matrix: 6 })); + } + + #[test] + fn absent_fields_take_the_standard_defaults() { + let no_vui = sps(|w| w.u(0, 1), |w| w.ue(0)); + assert_eq!(signal(&[&no_vui]), Some(Signal::default())); + let range_only = sps( + |w| { + w.u(1, 1); + w.u(0, 2); + w.u(1, 1); + w.u(5, 3); + w.u(1, 1); + w.u(0, 1); // no colour description + }, + |w| w.ue(0), + ); + assert_eq!(signal(&[&range_only]), Some(Signal { full_range: true, ..Signal::default() })); + } + + #[test] + fn missing_or_truncated_sps_is_none() { + assert_eq!(signal(&[]), None); + assert_eq!(signal(&[&[0x40, 1, 2, 3]]), None); + let nal = sps(|w| w.u(0, 1), |w| w.ue(0)); + for cut in 2..nal.len() - 2 { + // never panics; most cuts are None + let _ = signal(&[nal.get(..cut).unwrap_or_default()]); + } + assert_eq!(signal(&[&[SPS_NUT << 1, 1, 0xff, 0xff, 0xff]]), None); + } +} diff --git a/crates/heif/src/ycc.rs b/crates/heif/src/ycc.rs new file mode 100644 index 000000000..a0429b453 --- /dev/null +++ b/crates/heif/src/ycc.rs @@ -0,0 +1,326 @@ +//! YCbCr → RGB for decoded HEVC pictures, done here rather than in heic-rs so the result matches +//! libheif (the reference every HEIF tool is checked against) to within one code value: +//! +//! - **Which matrix and range.** The item's `colr` `nclx` box when there is one; otherwise the +//! HEVC VUI ([`crate::vui`]); otherwise the H.265 defaults (limited range). An unspecified matrix +//! is BT.601, as libheif reads it. heic-rs 0.1.1 ignores the VUI and assumes BT.709 limited +//! range for every file without `nclx`, which shifts every iPhone photo (full range, ICC only) +//! by about 8 code values. +//! - **Chroma upsampling.** Nearest neighbour (each chroma sample covers its 2×2 or 2×1 luma +//! block), as libheif does by default. libheif switches to a centred bilinear filter (weights +//! 9/3/3/1 sixteenths, in integers) when a `clap` crop starts at an odd column or row; so does +//! [`Upsampling::Bilinear`]. +//! +//! The arithmetic is floating point and the result 16-bit, whatever the coded depth, so the +//! develop pipeline gets every bit the conversion produces. + +use heic_rs::grid::Mosaic; +use heic_rs::hevc::{ChromaFormat, Frame}; + +/// The coded pictures one image is made of. +pub enum Planes<'a> { + Picture(&'a Frame), + Grid(Mosaic<'a>), +} + +impl Planes<'_> { + pub fn size(&self) -> (usize, usize) { + match self { + Planes::Picture(f) => (f.width as usize, f.height as usize), + Planes::Grid(m) => (m.width() as usize, m.height() as usize), + } + } + + pub fn bit_depth(&self) -> u8 { + match self { + Planes::Picture(f) => f.bit_depth, + Planes::Grid(m) => m.bit_depth(), + } + } + + pub fn chroma(&self) -> ChromaFormat { + match self { + Planes::Picture(f) => f.chroma, + Planes::Grid(m) => m.chroma(), + } + } + + fn chroma_width(&self) -> usize { + let (w, h) = self.size(); + self.chroma().chroma_size(w as u32, h as u32).0 as usize + } + + fn luma_row(&self, y: usize, dst: &mut [u16]) -> Option<()> { + match self { + Planes::Picture(f) => copy_row(&f.y, f.y_stride as usize, y, dst), + Planes::Grid(m) => m.luma_row(y, dst), + } + } + + fn chroma_row(&self, cr: bool, r: usize, dst: &mut [u16]) -> Option<()> { + match self { + Planes::Picture(f) => copy_row(if cr { &f.cr } else { &f.cb }, f.c_stride as usize, r, dst), + Planes::Grid(m) => m.chroma_row(cr, r, dst), + } + } +} + +fn copy_row(plane: &[u16], stride: usize, y: usize, dst: &mut [u16]) -> Option<()> { + let start = y.checked_mul(stride)?; + dst.copy_from_slice(plane.get(start..start.checked_add(dst.len())?)?); + Some(()) +} + +/// How the three coded channels map to R, G, B (H.273 `MatrixCoefficients`). +#[derive(Debug, Clone, Copy, PartialEq)] +enum Matrix { + /// 0: the channels are G, B, R. + Identity, + /// 8: YCgCo. + YCgCo, + /// Every Kr/Kb matrix: R = Y + `cr_r`·Cr, G = Y − `cb_g`·Cb − `cr_g`·Cr, B = Y + `cb_b`·Cb. + Kr { cr_r: f32, cb_g: f32, cr_g: f32, cb_b: f32 }, +} + +impl Matrix { + /// Unknown, reserved and unspecified codes are BT.601, like libheif. + fn from_code(code: u16) -> Matrix { + let (kr, kb) = match code { + 0 => return Matrix::Identity, + 8 => return Matrix::YCgCo, + 1 => (0.2126, 0.0722), + 4 => (0.30, 0.11), + 7 => (0.212, 0.087), + // 10 (BT.2020 constant luminance) is approximated by its non-constant matrix. + 9 | 10 => (0.2627, 0.0593), + _ => (0.299, 0.114), + }; + let kg = 1.0 - kr - kb; + Matrix::Kr { cr_r: 2.0 * (1.0 - kr), cb_g: 2.0 * kb * (1.0 - kb) / kg, cr_g: 2.0 * kr * (1.0 - kr) / kg, cb_b: 2.0 * (1.0 - kb) } + } +} + +/// How the samples are to be read: the matrix and the range. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Signal { + pub matrix: u16, + pub full_range: bool, +} + +/// How subsampled chroma is brought to the luma resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Upsampling { + Nearest, + /// Centred bilinear: chroma sits between its two luma columns (and rows for 4:2:0). + Bilinear, +} + +/// One chroma row brought to the full width `w` of luma row `y`. +fn chroma_at(planes: &Planes<'_>, cr: bool, y: usize, w: usize, mode: Upsampling, buf: &mut [u16], next: &mut [u16]) -> Option> { + let chroma = planes.chroma(); + let (xs, ys) = (chroma.x_shift(), chroma.y_shift()); + let (wf, hf) = planes.size(); + let rows = chroma.chroma_size(wf as u32, hf as u32).1 as usize; + let cy = y >> ys; + planes.chroma_row(cr, cy, buf)?; + let last = buf.len().checked_sub(1)?; + if mode == Upsampling::Nearest || xs == 0 { + return (0..w).map(|x| buf.get(x >> xs).copied()).collect(); + } + // The nearer neighbour on each axis takes a quarter of the weight. + let ny = if ys == 0 { + cy + } else if y.is_multiple_of(2) { + cy.saturating_sub(1) + } else { + (cy + 1).min(rows.checked_sub(1)?) + }; + planes.chroma_row(cr, ny, next)?; + (0..w) + .map(|x| { + let cx = x >> 1; + let nx = if x % 2 == 0 { cx.saturating_sub(1) } else { (cx + 1).min(last) }; + let (a, an) = (u32::from(*buf.get(cx)?), u32::from(*buf.get(nx)?)); + let v = if ys == 0 { + (3 * a + an + 2) >> 2 + } else { + let (b, bn) = (u32::from(*next.get(cx)?), u32::from(*next.get(nx)?)); + (9 * a + 3 * b + 3 * an + bn + 8) >> 4 + }; + u16::try_from(v).ok() + }) + .collect() +} + +/// Converts `planes` to interleaved 16-bit RGB, or RGBA with room left for alpha (filled with +/// 65535 here). `None` when the planes can't supply a row they promised (a malformed grid). +pub fn to_rgb16(planes: &Planes<'_>, signal: Signal, alpha: bool, mode: Upsampling) -> Option> { + let (w, h) = planes.size(); + let ch = if alpha { 4 } else { 3 }; + let bd = u32::from(planes.bit_depth()); + if !(8..=16).contains(&bd) || w == 0 || h == 0 { + return None; + } + let max = ((1u32 << bd) - 1) as f32; + let half = (1u32 << (bd - 1)) as f32; + let scale = (1u32 << (bd - 8)) as f32; + // Normalised Y = (v − y_off)·y_mul, Cb/Cr = (v − half)·c_mul. + let (y_off, y_mul, c_mul) = + if signal.full_range { (0.0, 1.0 / max, 1.0 / max) } else { (16.0 * scale, 1.0 / (219.0 * scale), 1.0 / (224.0 * scale)) }; + let matrix = Matrix::from_code(signal.matrix); + let mono = planes.chroma() == ChromaFormat::Monochrome; + let cw = planes.chroma_width(); + + let mut out = vec![0u16; w.checked_mul(h)?.checked_mul(ch)?]; + let row = |y: usize, dst: &mut [u16]| -> Option<()> { + let mut luma = vec![0u16; w]; + planes.luma_row(y, &mut luma)?; + let (cb, cr) = if mono { + (Vec::new(), Vec::new()) + } else { + let (mut buf, mut next) = (vec![0u16; cw], vec![0u16; cw]); + (chroma_at(planes, false, y, w, mode, &mut buf, &mut next)?, chroma_at(planes, true, y, w, mode, &mut buf, &mut next)?) + }; + for (x, (px, &yv)) in dst.chunks_exact_mut(ch).zip(&luma).enumerate() { + let rgb = if mono { + let v = (f32::from(yv) - y_off) * y_mul; + [v, v, v] + } else { + let (u, v) = (f32::from(*cb.get(x)?), f32::from(*cr.get(x)?)); + match matrix { + // G, B, R all read like luma. + Matrix::Identity => [(v - y_off) * y_mul, (f32::from(yv) - y_off) * y_mul, (u - y_off) * y_mul], + Matrix::YCgCo => { + let (yn, cg, co) = ((f32::from(yv) - y_off) * y_mul, (u - half) * c_mul, (v - half) * c_mul); + let t = yn - cg; + [t + co, yn + cg, t - co] + } + Matrix::Kr { cr_r, cb_g, cr_g, cb_b } => { + let (yn, u, v) = ((f32::from(yv) - y_off) * y_mul, (u - half) * c_mul, (v - half) * c_mul); + [yn + cr_r * v, yn - cb_g * u - cr_g * v, yn + cb_b * u] + } + } + }; + for (o, c) in px.iter_mut().zip(rgb) { + *o = (c.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; + } + if alpha && let Some(a) = px.get_mut(3) { + *a = u16::MAX; + } + } + Some(()) + }; + let row_len = w * ch; + #[cfg(not(target_arch = "wasm32"))] + let ok = { + use rayon::prelude::*; + out.par_chunks_mut(row_len).enumerate().all(|(y, dst)| row(y, dst).is_some()) + }; + #[cfg(target_arch = "wasm32")] + let ok = out.chunks_mut(row_len).enumerate().all(|(y, dst)| row(y, dst).is_some()); + ok.then_some(out) +} + +/// Writes an alpha picture's luma, full range, into channel 3 of `rgba`. +pub fn put_alpha(planes: &Planes<'_>, rgba: &mut [u16]) -> Option<()> { + let (w, h) = planes.size(); + let max = ((1u32 << u32::from(planes.bit_depth()).clamp(1, 16)) - 1) as f32; + let mut luma = vec![0u16; w]; + for (y, row) in rgba.chunks_exact_mut(w * 4).enumerate().take(h) { + planes.luma_row(y, &mut luma)?; + for (px, &a) in row.as_chunks_mut::<4>().0.iter_mut().zip(&luma) { + let v = (f32::from(a) / max).clamp(0.0, 1.0); + px[3] = (v * 65535.0 + 0.5) as u16; + } + } + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(w: u32, h: u32, bd: u8, chroma: ChromaFormat, y: u16, cb: u16, cr: u16) -> Frame { + let (cw, chh) = chroma.chroma_size(w, h); + let n = (cw * chh) as usize; + Frame { + width: w, + height: h, + bit_depth: bd, + chroma, + y: vec![y; (w * h) as usize], + cb: vec![cb; n], + cr: vec![cr; n], + y_stride: w, + c_stride: cw, + } + } + + fn rgb(f: &Frame, matrix: u16, full_range: bool) -> [u16; 3] { + let v = to_rgb16(&Planes::Picture(f), Signal { matrix, full_range }, false, Upsampling::Nearest).unwrap(); + [v[0], v[1], v[2]] + } + + #[test] + fn neutral_and_primaries_convert_exactly() { + // Full-range grey stays grey; limited-range black and white reach the ends. + assert_eq!(rgb(&frame(4, 4, 8, ChromaFormat::Yuv420, 128, 128, 128), 6, true), [128 * 257; 3]); + assert_eq!(rgb(&frame(4, 4, 8, ChromaFormat::Yuv420, 16, 128, 128), 1, false), [0; 3]); + assert_eq!(rgb(&frame(4, 4, 8, ChromaFormat::Yuv420, 235, 128, 128), 1, false), [65535; 3]); + assert_eq!(rgb(&frame(4, 4, 10, ChromaFormat::Yuv420, 940, 512, 512), 9, false), [65535; 3]); + // BT.601 full-range pure red: Y = 76.245, Cb = 84.97, Cr = 255.5 → (255, 0, 0) within rounding. + let red = rgb(&frame(2, 2, 8, ChromaFormat::Yuv444, 76, 85, 255), 6, true); + assert!(red[0] > 65000 && red[1] < 300 && red[2] < 300, "{red:?}"); + // Identity: the channels are G, B, R. + assert_eq!(rgb(&frame(2, 2, 8, ChromaFormat::Yuv444, 10, 20, 30), 0, true), [30 * 257, 10 * 257, 20 * 257]); + // Monochrome. + assert_eq!(rgb(&frame(2, 2, 8, ChromaFormat::Monochrome, 51, 0, 0), 6, true), [51 * 257; 3]); + } + + #[test] + fn chroma_is_nearest_neighbour() { + // 4:2:0, 4×2 luma, chroma 2×1: the left chroma sample covers columns 0–1 only. + let mut f = frame(4, 2, 8, ChromaFormat::Yuv420, 128, 128, 128); + f.cr = vec![200, 128]; + let v = to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, false, Upsampling::Nearest).unwrap(); + let red: Vec = v.chunks(3).map(|p| p[0]).collect(); + assert!(red[0] == red[1] && red[1] == red[4] && red[0] > red[2] && red[2] == 128 * 257, "{red:?}"); + } + + #[test] + fn bilinear_chroma_is_centred_9_3_3_1() { + // 4:2:0, 4×4 luma, chroma 2×2 with one bright corner sample. + let mut f = frame(4, 4, 8, ChromaFormat::Yuv420, 128, 128, 128); + f.cr = vec![200, 128, 128, 128]; + let p = Planes::Picture(&f); + let (mut buf, mut next) = (vec![0; 2], vec![0; 2]); + let rows: Vec> = (0..4).map(|y| chroma_at(&p, true, y, 4, Upsampling::Bilinear, &mut buf, &mut next).unwrap()).collect(); + // Row 0 sits nearest chroma row 0 (and clamps upward); row 1 blends a quarter of row 1. + assert_eq!(rows[0], [200, 182, 146, 128]); + assert_eq!(rows[1], [182, 169, 142, 128]); + assert_eq!(rows[2], [146, 142, 133, 128]); + assert_eq!(rows[3], [128; 4]); + assert_eq!(chroma_at(&p, true, 1, 4, Upsampling::Nearest, &mut buf, &mut next).unwrap(), [200, 200, 128, 128]); + } + + #[test] + fn alpha_and_bad_planes() { + let f = frame(2, 2, 8, ChromaFormat::Yuv420, 128, 128, 128); + let mut v = to_rgb16(&Planes::Picture(&f), Signal { matrix: 6, full_range: true }, true, Upsampling::Nearest).unwrap(); + assert_eq!(v[3], 65535); + put_alpha(&Planes::Picture(&frame(2, 2, 8, ChromaFormat::Monochrome, 51, 0, 0)), &mut v).unwrap(); + assert_eq!(v[3], 51 * 257); + let mut short = frame(4, 4, 8, ChromaFormat::Yuv420, 128, 128, 128); + short.y.truncate(5); + assert!(to_rgb16(&Planes::Picture(&short), Signal { matrix: 6, full_range: true }, false, Upsampling::Bilinear).is_none()); + assert!( + to_rgb16( + &Planes::Picture(&frame(2, 2, 7, ChromaFormat::Yuv420, 0, 0, 0)), + Signal { matrix: 6, full_range: true }, + false, + Upsampling::Nearest + ) + .is_none() + ); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 974de0232..acbecb60b 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -32,7 +32,7 @@ impl Drop for Headless { impl Default for Headless { fn default() -> Self { - Self::new(Session::new().with_fs().with_default_denoise_models().with_default_face_models()) + Self::new(Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) } } @@ -43,7 +43,7 @@ impl Headless { /// A headless session with the procedurally generated demo library. pub fn demo() -> Self { - Self::new(Session::with_demo().with_fs().with_default_denoise_models().with_default_face_models()) + Self::new(Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models()) } fn photo_or_active(&self, p: &Value) -> Result { @@ -131,7 +131,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } @@ -201,6 +201,14 @@ pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> mod tests { use super::*; + /// A headless session stamps imports and edits with the system clock, not the engine's fixed test clock. + #[test] + fn headless_uses_the_system_clock() { + const FIXED: &str = "2026-09-30T12:00:00"; + assert_ne!((Headless::default().session.clock)(), FIXED); + assert_ne!((Headless::demo().session.clock)(), FIXED); + } + /// `app.export` without `ids` exports the selection (as the desktop app does), else the active photo. #[test] fn export_follows_the_selection() { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 3393e5040..e09c9d4c9 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -371,3 +371,31 @@ fn modern_requests_receive_result_and_cache_fields() { } assert!(rpc(&mut s, 2, "tools/list", json!({}))["result"].get("resultType").is_none()); } + +/// A running app's control channel, as far as the MCP server can tell: it records each call. +struct Recorder(Vec<(String, Value)>); + +impl Backend for Recorder { + fn call(&mut self, method: &str, params: Value) -> Result { + self.0.push((method.to_string(), params)); + Ok(Value::Null) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "recorder".into() + } +} + +/// With the app attached, agents cut, copy and paste in its text fields (`clipboard`). +#[test] +fn the_clipboard_tool_cuts_copies_and_pastes_in_the_app() { + let names: Vec = crate::tools::helper_tools(true).iter().filter_map(|t| t["name"].as_str().map(str::to_string)).collect(); + assert!(names.iter().any(|n| n == "clipboard"), "{names:?}"); + assert!(!crate::tools::helper_tools(false).iter().any(|t| t["name"] == "clipboard"), "not without the app"); + let mut b = Recorder(vec![]); + let r = call_tool(&mut b, "clipboard", &json!({"action": "paste", "text": "travel"})); + assert!(!r.is_error, "{r:?}"); + assert_eq!(b.0, vec![("ui.clipboard".to_string(), json!({"action": "paste", "text": "travel"}))]); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 4a114f18c..dd52da402 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -258,6 +258,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, + "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], @@ -305,6 +306,13 @@ pub fn helper_tools(has_ui: bool) -> Vec { &["key"], ), tool("type_text", "Type text", "Type text into the focused field.", json!({"text": {"type": "string"}}), &["text"]), + tool( + "clipboard", + "Cut, copy or paste", + "Cut, copy or paste in the focused text field (click one first), as ⌘X / ⌘C / ⌘V do; cut and copy write the system clipboard. `paste` pastes `text`, or what the system clipboard holds without it. inspect_ui → `copied` is the text the app last copied.", + json!({"action": {"type": "string", "enum": ["cut", "copy", "paste"]}, "text": {"type": "string"}}), + &["action"], + ), tool( "pointer_gesture", "Pointer gesture", @@ -729,6 +737,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "watermark", "colorSpace", "bitDepth", + "hdr", "preset", "background", "resize", @@ -757,6 +766,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { } "press_key" => ToolResult::from(b.call("ui.key", obj(args, &["key", "cmd", "shift", "alt", "ctrl"]))), "type_text" => ToolResult::from(b.call("ui.text", obj(args, &["text"]))), + "clipboard" => ToolResult::from(b.call("ui.clipboard", obj(args, &["action", "text"]))), "pointer_gesture" => ToolResult::from(b.call("ui.pointer", obj(args, &["events", "alt", "shift", "cmd"]))), other => ToolResult::error(format!("unknown tool `{other}` (see tools/list)")), } diff --git a/crates/pipeline/src/auto.rs b/crates/pipeline/src/auto.rs index 3455fff21..f3259b5d9 100644 --- a/crates/pipeline/src/auto.rs +++ b/crates/pipeline/src/auto.rs @@ -1,4 +1,5 @@ -//! Auto tone and auto white balance (histogram / grey-world statistics on a proxy). +//! Auto tone (a Lightroom-like recipe, see [`auto_tone`]) and auto white balance (grey-world +//! statistics on a proxy). use lightcraft_color::cct::xy_to_temp_tint; use lightcraft_color::{REC2020, Xy, bradford, luminance_2020}; @@ -21,40 +22,83 @@ pub struct AutoTone { pub saturation: f64, } -fn percentile(sorted: &[f32], q: f32) -> f32 { - if sorted.is_empty() { - return 0.0; +/// Exposure in EV that Auto adds per EV of the scene's log-average luminance (the slope is negative: +/// darker scenes get more). The slope is Lightroom's: regressing the Exposure its Auto chose on 24 +/// raw photos against our own [`scene_log_mean`] of each gives -0.41 EV/EV (r = -0.92). +const EXPOSURE_SLOPE: f64 = -0.41; +/// Exposure for a scene whose log-average luminance is middle grey: fitted, with [`CONTRAST`] and +/// [`VIBRANCE`], so our renders match Lightroom's Auto results on those photos (see [`auto_tone`]). +const EXPOSURE_AT_GREY: f64 = -0.58; +/// Highlights and Shadows are Lightroom's own: the average its Auto chose on the 24 photos (our +/// sliders aim at the same as its). Freeing them as well matched no better on held-out photos and +/// left them poorly determined. +const HIGHLIGHTS: f64 = -66.0; +const SHADOWS: f64 = 52.0; +/// Contrast and Vibrance are fitted: our slider scales differ from Lightroom's, and our base tone is +/// flatter, so Contrast is far above its +6. Whites, Blacks and Saturation stay at 0 (Whites and +/// Blacks made no difference to the match; Lightroom leaves Saturation at about 0). +const CONTRAST: f64 = 68.0; +const VIBRANCE: f64 = 30.0; + +/// The scene's log-average luminance under the current white balance, in EV relative to middle grey +/// (the mean of log2(Y / 0.18) over a 512 px proxy, scene-linear, before any tone curve). Pixels that +/// aren't finite (a damaged decode) are left out: they get no weight when the proxy is made, and +/// proxy pixels made only of them are skipped. `None` when no pixel is valid. +pub fn scene_log_mean(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option { + let (mut clean, mut weight) = (src.clone(), Rgb32f::new(src.width, src.height)); + let mut any = false; + for (p, w) in clean.data.iter_mut().zip(weight.data.iter_mut()) { + if p.iter().all(|v| v.is_finite()) { + *w = [1.0; 3]; + any = true; + } else { + *p = [0.0; 3]; + } } - sorted[((sorted.len() - 1) as f32 * q.clamp(0.0, 1.0)) as usize] + if !any { + return None; + } + let fit = |img: &Rgb32f| lightcraft_raster::resample::fit(img, 512, 512, lightcraft_raster::resample::Filter::Box); + let (img, weight) = (fit(&clean), fit(&weight)); + // the mean of the valid pixels under each proxy pixel + let kept: Vec<[f32; 3]> = img.data.iter().zip(&weight.data).filter(|(_, w)| w[0] > 1e-6).map(|(p, w)| p.map(|v| v / w[0])).collect(); + let mut kept = Rgb32f { width: kept.len(), height: 1, data: kept }; + let base = DevelopSettings { wb: s.wb, process: s.process, ..DevelopSettings::default() }; + // (white balance is per pixel: the valid pixels can be balanced in a row of their own) + crate::local::scene_linear_pre(&mut kept, info, &base); + let (mut sum, mut n) = (0f64, 0usize); + for c in &kept.data { + let ev = (luminance_2020(*c).max(1e-6) / 0.18).log2(); + if ev.is_finite() { + sum += ev as f64; + n += 1; + } + } + (n > 0).then(|| sum / n as f64) } -/// Compute auto tone values for `src` under the current white balance (ignores current tone values). +/// Auto settings for `src` under the current white balance (ignores current tone values), aiming at +/// what Lightroom's Auto does: the Exposure a scene needs follows its log-average luminance +/// ([`scene_log_mean`], scene-linear, so the rule doesn't depend on the base tone curve), and the +/// other sliders are one fixed recipe. Calibrated black-box against Lightroom's observed behaviour +/// (the values its Auto chose and its exported results) on 24 Sony raw photos, of which 16 have a +/// starting look that Sony's in-camera DRO doesn't brighten. Held out one photo at a time, those 16 +/// come out at a median dE76 of 6.4 from Lightroom's Auto results (8.3 unedited; 18.8 with the +/// previous percentile rule, which ended about 16 L* too bright); all 24 at 7.7 (7.9 unedited). +/// Rendered sources (JPEGs) are already toned, so their log-average reads brighter and Auto changes +/// their exposure less. A source without a single valid pixel gets Exposure 0 (and the rest of the +/// recipe). pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTone { - let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box); - let mut base = DevelopSettings { wb: s.wb, ..DevelopSettings::default() }; - base.light.exposure = 0.0; - crate::local::scene_linear_pre(&mut img, info, &base); - let mut ev: Vec = img.data.iter().map(|c| (luminance_2020(*c).max(1e-6) / 0.18).log2()).collect(); - ev.sort_by(|a, b| a.total_cmp(b)); - let median = percentile(&ev, 0.5); - let exposure = (-median * 0.85 - 0.1).clamp(-4.0, 4.0); - let (p01, p05, p95, p995) = - (percentile(&ev, 0.01) + exposure, percentile(&ev, 0.05) + exposure, percentile(&ev, 0.95) + exposure, percentile(&ev, 0.995) + exposure); - let highlights = if p995 > 2.2 { -((p995 - 2.2) * 38.0).min(90.0) } else { 0.0 }; - let shadows = if p05 < -4.0 { ((-4.0 - p05) * 22.0).min(70.0) } else { 0.0 }; - let spread = p95 - p05; - let contrast = ((6.5 - spread) * 6.0).clamp(-20.0, 30.0); - let whites = if p995 < 1.8 { ((1.8 - p995) * 25.0).min(40.0) } else { -((p995 - 3.5).max(0.0) * 10.0).min(30.0) }; - let blacks = if p01 > -5.0 { -((p01 + 5.0) * 10.0).min(35.0) } else { ((-7.0 - p01).max(0.0) * 8.0).min(20.0) }; + let exposure = scene_log_mean(src, info, s).map_or(0.0, |m| (EXPOSURE_AT_GREY + EXPOSURE_SLOPE * m).clamp(-2.0, 3.0)); AutoTone { - exposure: (exposure as f64 * 100.0).round() / 100.0, - contrast: contrast.round() as f64, - highlights: highlights.round() as f64, - shadows: shadows.round() as f64, - whites: whites.round() as f64, - blacks: blacks.round() as f64, - vibrance: 12.0, - saturation: 3.0, + exposure: (exposure * 100.0).round() / 100.0, + contrast: CONTRAST, + highlights: HIGHLIGHTS, + shadows: SHADOWS, + whites: 0.0, + blacks: 0.0, + vibrance: VIBRANCE, + saturation: 0.0, } } @@ -65,7 +109,7 @@ pub fn auto_tone(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> AutoTo pub fn auto_bw_mix(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> [f64; 8] { use lightcraft_color::perceptual::{lab_to_lch, oklab_from_2020}; let mut img = lightcraft_raster::resample::fit(src, 512, 512, lightcraft_raster::resample::Filter::Box); - let base = DevelopSettings { wb: s.wb, light: s.light, ..DevelopSettings::default() }; + let base = DevelopSettings { wb: s.wb, light: s.light, process: s.process, ..DevelopSettings::default() }; crate::local::scene_linear_pre(&mut img, info, &base); let gain = 2f32.powf(base.light.exposure as f32); let (mut mass, mut sum_l) = ([0f64; 8], [0f64; 8]); @@ -144,13 +188,47 @@ mod tests { use super::*; #[test] - fn dark_image_gets_positive_exposure() { - let img = Rgb32f::from_fn(64, 64, |x, _| [0.01 + x as f32 * 0.0003; 3]); - let a = auto_tone(&img, &SourceInfo::default(), &DevelopSettings::default()); - assert!(a.exposure > 1.5, "{a:?}"); - let bright = Rgb32f::from_fn(64, 64, |x, _| [0.8 + x as f32 * 0.01; 3]); - let b = auto_tone(&bright, &SourceInfo::default(), &DevelopSettings::default()); - assert!(b.exposure < -1.0, "{b:?}"); + fn exposure_follows_the_log_average_luminance() { + let flat = |y: f32| Rgb32f::filled(64, 64, [y; 3]); + let auto = |img: &Rgb32f| auto_tone(img, &SourceInfo::default(), &DevelopSettings::default()); + let log_mean = |img: &Rgb32f| scene_log_mean(img, &SourceInfo::default(), &DevelopSettings::default()).unwrap(); + // a scene at middle grey gets the exposure at grey; each EV darker adds 0.41 EV + assert!(log_mean(&flat(0.18)).abs() < 1e-3); + assert_eq!(auto(&flat(0.18)).exposure, -0.58); + assert!((auto(&flat(0.18 / 8.0)).exposure - (-0.58 + 3.0 * 0.41)).abs() < 1e-9); + // darker scenes get more, brighter ones less, within -2..3 EV + let ramp = |lo: f32, step: f32| Rgb32f::from_fn(64, 64, |x, _| [lo + x as f32 * step; 3]); + let (dark, bright) = (auto(&ramp(0.01, 0.0003)), auto(&ramp(0.8, 0.01))); + assert!(dark.exposure > 0.5 && bright.exposure < -1.0, "{dark:?} {bright:?}"); + assert_eq!(auto(&flat(1e-9)).exposure, 3.0); + assert_eq!(auto(&flat(1e6)).exposure, -2.0); + // the rest is one recipe + assert_eq!( + (dark.contrast, dark.highlights, dark.shadows, dark.whites, dark.blacks, dark.vibrance, dark.saturation), + (68.0, -66.0, 52.0, 0.0, 0.0, 30.0, 0.0) + ); + assert_eq!((dark.contrast, dark.shadows), (bright.contrast, bright.shadows)); + } + + #[test] + fn pixels_that_are_not_finite_are_left_out() { + let auto = |img: &Rgb32f| auto_tone(img, &SourceInfo::default(), &DevelopSettings::default()); + // half middle grey, half NaN (or infinite): the grey half alone decides, as if the rest weren't there + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let half = Rgb32f::from_fn(64, 64, |x, _| if x < 32 { [0.18; 3] } else { [bad; 3] }); + assert_eq!(auto(&half).exposure, -0.58, "{bad}"); + // also through the resampling of a source larger than the proxy (proxy pixels on the border mix both halves) + let big = Rgb32f::from_fn(1030, 700, |x, _| if x < 515 { [0.045; 3] } else { [bad; 3] }); + assert!((auto(&big).exposure - (-0.58 + 2.0 * 0.41)).abs() < 1e-9, "{bad}"); + // a few broken pixels scattered over a grey image change nothing + let speckled = Rgb32f::from_fn(1030, 700, |x, y| if (x * 7 + y * 13) % 97 == 0 { [bad; 3] } else { [0.18; 3] }); + assert_eq!(auto(&speckled).exposure, -0.58, "{bad}"); + } + // with no valid pixel at all (or none at all) there is nothing to measure: Exposure 0, the recipe as usual + for img in [Rgb32f::filled(8, 8, [f32::NAN; 3]), Rgb32f::filled(600, 600, [f32::INFINITY; 3]), Rgb32f::new(0, 0)] { + let a = auto(&img); + assert_eq!((a.exposure, a.contrast, a.vibrance), (0.0, 68.0, 30.0)); + } } #[test] diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 6b422ee9a..fce3b8324 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -3,7 +3,7 @@ use lightcraft_color::luminance_2020; use lightcraft_color::spline::{Lut1, MonotoneCurve}; use lightcraft_color::transfer::linear_to_srgb; -use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle}; +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Process, ToneCurve, VignetteStyle}; use lightcraft_geom::Point; use lightcraft_raster::Rgba8; @@ -192,6 +192,9 @@ pub struct FinishParams { pub px_per_long: f64, /// The window's offset and the whole output's size (pixels): `(0, 0, w, h)` unless windowed. pub view: [f32; 4], + /// Peak linear output relative to SDR white: 1 for SDR renders; `2^max_ev` for an HDR render + /// ([`OutputDepth::F32Hdr`] with HDR on), which keeps values above 1 instead of clipping them. + pub peak: f32, } impl FinishParams { @@ -220,16 +223,12 @@ impl FinishParams { ((s.grain.amount / 100.0) as f32 * 0.13, cell.max(0.6), (s.grain.roughness / 100.0) as f32, s.grain.seed) }); let calibration = s.section_enabled("calibration"); + let peak = s.hdr.peak(); FinishParams { + peak, calib: if calibration { crate::colorops::calibration_matrix(&s.calibration) } else { None }, shadow_tint: if calibration { (s.calibration.shadows_tint / 100.0) as f32 } else { 0.0 }, - tone: if let Some(curve) = info.camera_tone.as_ref().filter(|_| info.raw) { - ToneMap::camera(curve, s.light.contrast, s.light.whites, s.light.blacks) - } else if info.raw { - ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) - } else { - ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks) - }, + tone: base_tone(s, info, peak), lut: crate::lut::get(&s.profile.id).map(|l| (l, (s.profile.amount / 100.0).clamp(0.0, 2.0) as f32)), ops: ColorOps::new(s), curves: curve_luts(&s.curve), @@ -260,12 +259,49 @@ impl FinishParams { view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]), } } + + /// Render into a display's primaries (sRGB curve) instead of the output space, soft proof + /// included (see [`crate::DisplaySpace`]). + pub fn for_display(&mut self, d: &crate::DisplaySpace, proof: Option) { + self.to_out = d.from_working; + self.out_luma = d.luma; + self.out_trc = OutputTrc::Srgb; + self.proof = proof.map(|pr| pr.params_display(d)); + } } -pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, proof: Option) -> Rgba8 { +/// The base tone map (scene → display luminance, with Contrast, Whites and Blacks) of `s`'s +/// rendering process. This is where a new tone model branches: a retuned default tone curve (issue +/// #146) is a new [`Process`] with its own arm here, and V1's arm stays as it is, so photos edited +/// under V1 keep their look (`docs/process-versions.md`). CPU and GPU both take it from here. +/// `peak` is the HDR render's peak relative to SDR white ([`lightcraft_develop::Hdr::peak`]); at 1.0 (SDR) +/// every arm is the SDR tone map. The camera's look is bounded at white, so HDR renders use the HDR tone map. +pub fn base_tone(s: &DevelopSettings, info: &SourceInfo, peak: f32) -> ToneMap { + let l = &s.light; + match s.process.process() { + Process::V1 => match info.camera_tone.as_ref().filter(|_| info.raw && peak <= 1.0) { + Some(curve) => ToneMap::camera(curve, l.contrast, l.whites, l.blacks), + None if info.raw => ToneMap::hdr(l.contrast, l.whites, l.blacks, peak), + None => ToneMap::display_hdr(l.contrast, l.whites, l.blacks, peak), + }, + } +} + +pub(crate) fn finish( + p: &Prepared, + s: &DevelopSettings, + frame: &Frame, + info: &SourceInfo, + space: OutputSpace, + display: Option<&crate::DisplaySpace>, + proof: Option, +) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); fp.proof = proof.map(|pr| pr.params(space)); + if let Some(d) = display { + fp.for_display(d, proof); + } let trc = fp.out_trc; let data = finish_with(p, &fp, false, |e| match trc { OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255], @@ -298,6 +334,12 @@ pub(crate) fn finish_deep( let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0)))); DeepSamples::F32(v.into_flattened()) } + OutputDepth::F32Hdr => { + // the sRGB curve extended past 1 (it is unbounded), so values up to `peak` survive + let peak = fp.peak; + let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.max(0.0)).clamp(0.0, peak))); + DeepSamples::F32(v.into_flattened()) + } _ => { let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; let v = finish_with(p, &fp, true, |e| match trc { @@ -352,6 +394,8 @@ pub(crate) fn finish_with( let aspect = vw / vh; let srgb = srgb_lut(); + let peak = fp.peak; + let hdr = peak > 1.0; let mut out = vec![T::default(); w * h]; for_rows(&mut out, w, |y, row| { for (x, px) in row.iter_mut().enumerate() { @@ -493,8 +537,8 @@ pub(crate) fn finish_with( d = d.map(|v| o + (v - o) * k); } let mx = d[0].max(d[1]).max(d[2]); - if mx > 1.0 { - let t = ((mx - 1.0) / (mx - o).max(1e-6)).clamp(0.0, 1.0); + if mx > peak { + let t = ((mx - peak) / (mx - o).max(1e-6)).clamp(0.0, 1.0); d = d.map(|v| v + (o - v) * t); } @@ -526,6 +570,9 @@ pub(crate) fn finish_with( } else { d = d.map(|c| c * f); } + } else if hdr { + // lighten towards SDR white; never pull HDR highlights down to it + d = d.map(|c| c + (1.0 - c).max(0.0) * v.amount * t * 0.85); } else { d = d.map(|c| c + (1.0 - c) * v.amount * t * 0.85); } @@ -538,7 +585,7 @@ pub(crate) fn finish_with( let mut r = match &fp.proof { Some(pp) => { let q0 = mul3(&pp.to_proof, d); - let (q, t) = gamut_map(q0, pp.luma); + let (q, t) = gamut_map_to(q0, pp.luma, peak); if pp.dest_warning && out_of_gamut(q0, t) { warn = Some(crate::output::PROOF_DEST_WARNING); } @@ -546,17 +593,37 @@ pub(crate) fn finish_with( } None => mul3(to_out, d), }; - let (mapped, t) = gamut_map(r, *out_luma); + let (mapped, t) = gamut_map_to(r, *out_luma, peak); if fp.proof.is_some_and(|pp| pp.display_warning) && warn.is_none() && out_of_gamut(r, t) { warn = Some(crate::output::PROOF_DISPLAY_WARNING); } r = mapped; // --- encode, curves, grain - let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) }; + let mut e = if hdr { + // HDR: the (unbounded) sRGB curve, so values above SDR white encode above 1 + r.map(|v| { + if v > 1.0 { + linear_to_srgb(v) + } else if exact { + linear_to_srgb(v.max(0.0)) + } else { + encode_srgb(srgb, v) + } + }) + } else if exact { + r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) + } else { + r.map(|v| encode_srgb(srgb, v)) + }; if let Some(l) = curves { let e0 = e; - e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; + e = if hdr { + // above SDR white the curves continue with slope 1 from their end point + std::array::from_fn(|k| if e[k] > 1.0 { l[k].eval(1.0) + (e[k] - 1.0) } else { l[k].eval(e[k]) }) + } else { + [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])] + }; if fp.refine_sat < 1.0 { e = refine_saturation(e0, e, fp.refine_sat); } @@ -567,11 +634,20 @@ pub(crate) fn finish_with( let sc = fp.px_per_long as f32 / cell; let mut g = grain_noise(gx * sc, gy * sc, seed); g = g * (1.0 - rough * 0.5) + grain_noise(gx * sc * 2.3, gy * sc * 2.3, seed ^ 0x55) * rough * 0.7; - let lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + let mut lum = 0.2126 * e[0] + 0.7152 * e[1] + 0.0722 * e[2]; + if hdr { + lum = lum.clamp(0.0, 1.0); + } let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e.map(|v| v + k); } let e = match &p_lut { + Some((l, k)) if hdr => { + // the LUT sees SDR values; the part above SDR white is carried over + let c = e.map(|v| v.clamp(0.0, 1.0)); + let m = l.apply(c); + [e[0] + (m[0] - c[0]) * k, e[1] + (m[1] - c[1]) * k, e[2] + (m[2] - c[2]) * k] + } Some((l, k)) => { let m = l.apply(e.map(|v| v.clamp(0.0, 1.0))); [e[0] + (m[0] - e[0]) * k, e[1] + (m[1] - e[1]) * k, e[2] + (m[2] - e[2]) * k] @@ -609,13 +685,19 @@ fn mul3(m: &[[f32; 3]; 3], d: [f32; 3]) -> [f32; 3] { /// returns the mapped colour and the chroma scale used (1 = already in gamut). #[inline] pub fn gamut_map(r: [f32; 3], luma: [f32; 3]) -> ([f32; 3], f32) { - let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, 1.0); + gamut_map_to(r, luma, 1.0) +} + +/// [`gamut_map`] into `0..peak` (an HDR render's range; `peak` = 1 is [`gamut_map`]). +#[inline] +pub fn gamut_map_to(r: [f32; 3], luma: [f32; 3], peak: f32) -> ([f32; 3], f32) { + let yy = (luma[0] * r[0] + luma[1] * r[1] + luma[2] * r[2]).clamp(0.0, peak); let mut t = 1.0f32; for c in r { if c < 0.0 { t = t.min(yy / (yy - c).max(1e-9)); - } else if c > 1.0 { - t = t.min((1.0 - yy) / (c - yy).max(1e-9)); + } else if c > peak { + t = t.min((peak - yy) / (c - yy).max(1e-9)); } } if t < 1.0 { (r.map(|c| yy + (c - yy) * t), t) } else { (r, 1.0) } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 274bcd61c..f7c33ee9e 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -43,7 +43,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc, Proof}; +pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof}; pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -132,6 +132,9 @@ pub struct RenderRequest { /// Render only this window of the output that `max_w × max_h` describes (a zoomed view): /// the result is that window's pixels, as in the whole render. See [`PixelWindow`]. pub window: Option, + /// Previews for a monitor with a display profile: render 8-bit into its primaries instead of + /// `space` (see [`DisplaySpace`]; ignored by deep renders). + pub display: Option, } /// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered @@ -168,6 +171,7 @@ impl RenderRequest { depth: OutputDepth::U8, proof: None, window: None, + display: None, } } } @@ -448,6 +452,13 @@ pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) { redeye::apply(img, &p.eyes); } +/// The settings a render of `s` for `req` uses: the HDR edit for an [`OutputDepth::F32Hdr`] +/// render, otherwise its SDR rendition (see [`DevelopSettings::sdr_rendition`]). Every renderer +/// (CPU and GPU) goes through this, so an SDR render of an HDR edit looks the same everywhere. +pub fn settings_for<'a>(s: &'a DevelopSettings, req: &RenderRequest) -> std::borrow::Cow<'a, DevelopSettings> { + if req.depth == OutputDepth::F32Hdr { std::borrow::Cow::Borrowed(s) } else { s.sdr_rendition() } +} + /// Render `src` with settings `s`. pub fn render(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) -> Rendered { render_impl(Src::Borrowed(src), info, s, req, None) @@ -467,7 +478,9 @@ enum Src<'a> { fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, cache: Option<&StageCache>) -> Rendered { // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); - let s: &DevelopSettings = &effective; + let edit: &DevelopSettings = &effective; + let rendition = settings_for(edit, req); + let s: &DevelopSettings = &rendition; // `Instant::now()` panics on wasm32-unknown-unknown: only read the clock when profiling. let lap = |what: &str, t: &mut Option| { if let Some(t) = t { @@ -525,7 +538,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("finish (deep)", &mut t); return Rendered { image, histogram, deep: Some(deep) }; } - let image = finish::finish(&prep, s, frame, info, req.space, req.proof); + let image = finish::finish(&prep, s, frame, info, req.space, req.display.as_ref(), req.proof); lap("finish", &mut t); let cut = |i: &Rgba8| match plan.keep { Some(k) => i.crop(k.x, k.y, k.w, k.h), @@ -539,7 +552,15 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende let mut image = image; let mask = overlay_alpha(req.overlay, &plan, &prep); visualize::apply(&mut image, req.overlay, &plan, mask.as_ref()); - let image = if plan.keep.is_some() { cut(&image) } else { image }; + let mut image = if plan.keep.is_some() { cut(&image) } else { image }; + if req.overlay == Overlay::HdrRange && edit.hdr.enabled { + // the HDR rendition of the same request says how far above SDR white each pixel goes + let hreq = RenderRequest { depth: OutputDepth::F32Hdr, overlay: Overlay::None, proof: None, ..*req }; + if let Some(hdr) = render(src_img, info, edit, &hreq).deep { + visualize::hdr_range(&mut image, &hdr, req.space.luma()); + } + lap("visualize hdr", &mut t); + } Rendered { image, histogram, deep: None } } @@ -583,9 +604,9 @@ fn overlay_alpha(o: Overlay, plan: &Plan<'_>, prep: &Prepared) -> Option } /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" -/// (default look, keeping the crop so framing matches). +/// (default look, keeping the crop so framing matches, under the photo's own rendering process). pub fn before_settings(s: &DevelopSettings) -> DevelopSettings { - let mut b = DevelopSettings { crop: s.crop, orientation: s.orientation, ..DevelopSettings::default() }; + let mut b = DevelopSettings { crop: s.crop, orientation: s.orientation, process: s.process, ..DevelopSettings::default() }; b.wb = lightcraft_develop::WhiteBalance { mode: lightcraft_develop::WbMode::AsShot, ..b.wb }; b } @@ -620,6 +641,8 @@ mod tests; #[cfg(test)] mod tests_geometry; #[cfg(test)] +mod tests_hdr; +#[cfg(test)] mod tests_local; #[cfg(test)] mod tests_window; diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 5272b54d1..5acae0921 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -1,13 +1,14 @@ //! Output encoding: the colour space (primaries + transfer curve) and sample format a render is -//! delivered in. Previews are always 8-bit sRGB; exports may ask for a wide-gamut space and/or -//! 16-bit or linear float samples. +//! delivered in. Previews are 8-bit sRGB, or 8-bit in a monitor's own primaries when the frontend +//! has a display profile ([`DisplaySpace`]); exports may ask for a wide-gamut space and/or 16-bit or +//! linear float samples. //! //! The per-pixel stage works in scene-linear Rec.2020 and ends by converting to the target's //! primaries, gamut mapping into the *target* gamut, and encoding. Tone curves and grain operate on //! sRGB-curve-encoded values of the target primaries (identical to the sRGB path when the target is //! sRGB); afterwards the values are re-encoded with the target's own curve. -use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, PROPHOTO, REC2020, RgbSpace, SRGB}; +use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, Mat3, PROPHOTO, REC2020, RgbSpace, SRGB}; use serde::{Deserialize, Serialize}; /// The RGB space an image is rendered into. @@ -138,6 +139,48 @@ impl OutputSpace { } } +/// A monitor's RGB as a preview target (from its ICC display profile): previews render into the +/// display's own primaries, gamut mapped into the display's gamut, encoded with the sRGB curve. +/// The frontend then takes that encoding to the display's real device values +/// (`lightcraft_codecs::display`). Exports never use it. +#[derive(Clone, Copy, Debug)] +pub struct DisplaySpace { + /// Linear Rec.2020 → linear display RGB, and back. + pub from_working: [[f32; 3]; 3], + pub to_working: [[f32; 3]; 3], + /// Luminance weights of the display's linear RGB (gamut mapping). + pub luma: [f32; 3], + /// Identifies the display profile (part of render keys). + pub id: u64, +} + +impl DisplaySpace { + /// From the display's linear RGB → linear Rec.2020 matrix (display white → (1, 1, 1)). + /// `None` for a singular or non-finite matrix. + pub fn new(to_working: Mat3, id: u64) -> Option { + let from = to_working.inverse()?; + let luma = REC2020.to_xyz().mul(&to_working).0[1]; + let all = to_working.0.iter().chain(from.0.iter()).flatten().chain(luma.iter()); + if all.clone().any(|v| !v.is_finite()) || (luma.iter().sum::() - 1.0).abs() > 1e-3 { + return None; + } + Some(DisplaySpace { from_working: from.to_f32(), to_working: to_working.to_f32(), luma: luma.map(|v| v as f32), id }) + } + + /// A display that is exactly `space`'s primaries (tests, and the sRGB identity). + pub fn of(space: OutputSpace, id: u64) -> Option { + DisplaySpace::new(space.rgb_space().to_space(&REC2020), id) + } +} + +impl PartialEq for DisplaySpace { + fn eq(&self, o: &Self) -> bool { + self.id == o.id + } +} + +impl Eq for DisplaySpace {} + /// Soft proofing: render as if the result were converted to `space` (colours outside its gamut /// are mapped into it, then shown in the render's own space), optionally painting what the /// destination can't hold (`dest_warning`, red) and what the display can't show @@ -179,6 +222,17 @@ impl Proof { display_warning: self.display_warning, } } + + /// [`Self::params`] for a render into a display's primaries: the display warning then shows + /// what *this* display can't show. + pub fn params_display(self, d: &DisplaySpace) -> ProofParams { + let proof_to_working = self.space.rgb_space().to_space(&REC2020).to_f32(); + ProofParams { proof_to_out: mul33(&d.from_working, &proof_to_working), ..self.params(OutputSpace::Srgb) } + } +} + +fn mul33(a: &[[f32; 3]; 3], b: &[[f32; 3]; 3]) -> [[f32; 3]; 3] { + std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) } /// Sample format of a render. @@ -192,6 +246,9 @@ pub enum OutputDepth { U16, /// 32-bit float *linear* RGB (target primaries, 0..1) in [`crate::Rendered::deep`]. F32Linear, + /// 32-bit float linear RGB (target primaries) of the HDR render: SDR white = 1, highlights up + /// to [`lightcraft_develop::Hdr::peak`]. With HDR off this is [`OutputDepth::F32Linear`]. + F32Hdr, } /// High-bit-depth RGB samples (3 per pixel, interleaved, row-major). diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index f572b21d2..185d96b31 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -360,6 +360,46 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { assert!(red(&pro) < n, "ProPhoto holds more than sRGB"); } +/// A preview for a monitor with a display profile renders into the display's primaries: for a +/// display that *is* a standard space, exactly what an 8-bit render into that space gives. +#[test] +fn display_space_renders_like_the_matching_output_space() { + use crate::{DisplaySpace, OutputSpace, Proof}; + let src = scene(); + let mut s = DevelopSettings::default(); + s.color.saturation = 100.0; + s.color.vibrance = 100.0; + let info = SourceInfo { raw: true, ..Default::default() }; + let req = |space, display, proof| RenderRequest { space, display, proof, ..RenderRequest::fit(120, 120) }; + for space in [OutputSpace::Srgb, OutputSpace::DisplayP3] { + let d = DisplaySpace::of(space, 7).unwrap(); + let want = render(&src, &info, &s, &req(space, None, None)).image; + let got = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(d), None)).image; + assert!(max_diff(&got, &want) <= 1, "{space:?}: {}", max_diff(&got, &want)); + } + // a wide display shows the saturated colours sRGB clips + let srgb = render(&src, &info, &s, &req(OutputSpace::Srgb, None, None)).image; + let p3 = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::DisplayP3, 1).unwrap()), None)).image; + assert!(max_diff(&srgb, &p3) > 4); + + // soft proofing on a display: the display gamut warning is about *this* display + let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count(); + let pro = Some(Proof { space: OutputSpace::ProPhoto, dest_warning: false, display_warning: true }); + let on_srgb = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Srgb, 2).unwrap()), pro)).image); + let on_wide = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Rec2020, 3).unwrap()), pro)).image); + assert!(on_srgb > 0 && on_wide < on_srgb, "{on_srgb} {on_wide}"); + let plain = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, None, pro)).image); + assert_eq!(plain, on_srgb, "an sRGB display warns like no display profile"); +} + +#[test] +fn display_space_rejects_degenerate_matrices() { + use crate::DisplaySpace; + assert!(DisplaySpace::new(lightcraft_color::Mat3([[1.0, 1.0, 0.0], [1.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), 1).is_none()); + assert!(DisplaySpace::new(lightcraft_color::Mat3([[f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), 1).is_none()); + assert!(DisplaySpace::new(lightcraft_color::Mat3::IDENTITY, 1).is_some()); +} + #[test] fn tint_negative_is_green_and_positive_is_magenta() { use lightcraft_develop::WbMode; @@ -435,3 +475,49 @@ fn custom_white_balance_redevelops_in_camera_space() { s.wb.mode = WbMode::AsShot; assert!(wb_matrix_for(&info, &s).is_none()); } + +/// Every stored process number renders with a process this build knows: settings saved before +/// process versions existed exactly as V1, and a number from a newer LightCraft exactly as the +/// latest process here (`docs/process-versions.md`). Raw (base tone curve), camera-tone and +/// rendered sources, plain and edited, 8-bit and 16-bit. +#[test] +fn stored_process_numbers_render_with_a_known_process() { + use lightcraft_develop::{Process, ProcessVersion}; + let src = scene(); + let curve = crate::tone::CameraTone::new(std::array::from_fn(|i| { + let x = 0.004 * 1.18f32.powi(i as i32); + [x, 1.0 - (-2.0 * x).exp()] + })) + .unwrap(); + let infos = [ + SourceInfo::default(), + SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 4.0, ..Default::default() }, + SourceInfo { raw: true, relative_wb: true, camera_tone: Some(curve), ..Default::default() }, + ]; + let mut edited = DevelopSettings::for_raw(5200.0, 4.0); + for (id, v) in [("light.exposure", 0.4), ("light.contrast", 30.0), ("light.highlights", -60.0), ("light.whites", 15.0), ("light.blacks", -20.0)] { + controls::set(&mut edited, id, v); + } + let legacy = |s: &DevelopSettings| { + let mut v = s.to_json(); + v.as_object_mut().unwrap().remove("process"); + DevelopSettings::from_json(&v).unwrap() + }; + let with = |s: &DevelopSettings, p: ProcessVersion| DevelopSettings { process: p, ..s.clone() }; + let deep = RenderRequest { depth: crate::OutputDepth::U16, ..RenderRequest::fit(96, 96) }; + for info in &infos { + for s in [DevelopSettings::default(), edited.clone()] { + for req in [RenderRequest::fit(96, 96), deep] { + let out = |s: &DevelopSettings| { + let r = render(&src, info, s, &req); + (r.image.data, r.deep) + }; + let v1 = out(&with(&s, ProcessVersion::V1)); + assert_eq!(legacy(&s).process, ProcessVersion::V1); + assert!(out(&legacy(&s)) == v1, "saved before process versions: rendered as V1"); + let latest = out(&with(&s, Process::LATEST.version())); + assert!(out(&with(&s, ProcessVersion(Process::LATEST.version().0 + 7))) == latest, "newer than this build: as the latest"); + } + } + } +} diff --git a/crates/pipeline/src/tests_hdr.rs b/crates/pipeline/src/tests_hdr.rs new file mode 100644 index 000000000..bacc0eaa3 --- /dev/null +++ b/crates/pipeline/src/tests_hdr.rs @@ -0,0 +1,107 @@ +//! HDR editing: the HDR render ([`OutputDepth::F32Hdr`]) and the SDR rendition every other render uses. + +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::Rgb32f; + +use crate::{DeepSamples, OutputDepth, RenderRequest, SourceInfo, render}; + +fn sunset() -> Rgb32f { + let lib = lightcraft_scenes::demo_library(); + let s = lib.iter().find(|s| s.kind == lightcraft_scenes::Kind::OceanSunset).map(|s| s.render(240, 160)); + s.unwrap_or_else(|| lib[0].render(240, 160)) +} + +fn raw() -> SourceInfo { + SourceInfo { raw: true, ..Default::default() } +} + +fn hdr_on(max_ev: f64) -> DevelopSettings { + let mut s = DevelopSettings::default(); + s.hdr.enabled = true; + s.hdr.max_ev = max_ev; + s +} + +fn deep(src: &Rgb32f, s: &DevelopSettings, depth: OutputDepth) -> Vec { + let req = RenderRequest { depth, ..RenderRequest::fit(240, 240) }; + match render(src, &raw(), s, &req).deep.map(|d| d.samples) { + Some(DeepSamples::F32(v)) => v, + other => panic!("expected float samples, got {other:?}"), + } +} + +#[test] +fn hdr_render_keeps_highlights_up_to_the_peak() { + let src = sunset(); + let v = deep(&src, &hdr_on(3.0), OutputDepth::F32Hdr); + assert!(v.iter().all(|x| x.is_finite() && (0.0..=8.0 + 1e-4).contains(x))); + let max = v.iter().copied().fold(0.0f32, f32::max); + assert!(max > 1.5, "the sun should rise above SDR white, max {max}"); + // a smaller headroom limit caps lower + let v1 = deep(&src, &hdr_on(1.0), OutputDepth::F32Hdr); + assert!(v1.iter().all(|x| *x <= 2.0 + 1e-4)); +} + +#[test] +fn hdr_midtones_match_the_sdr_render() { + let src = sunset(); + let hdr = deep(&src, &hdr_on(3.0), OutputDepth::F32Hdr); + let sdr = deep(&src, &DevelopSettings::default(), OutputDepth::F32Linear); + // pixels whose brightest channel is a midtone (bright saturated colours legitimately differ: + // SDR desaturates them at white, HDR keeps their colour) + let mut n = 0; + for (h, s) in hdr.as_chunks::<3>().0.iter().zip(sdr.as_chunks::<3>().0) { + if s.iter().all(|v| *v < 0.15) { + for (a, b) in h.iter().zip(s) { + assert!((a - b).abs() < 2e-3, "{h:?} vs {s:?}"); + } + n += 1; + } + } + assert!(n > 100, "the scene should have midtones and shadows ({n})"); +} + +#[test] +fn f32hdr_without_hdr_is_the_sdr_float_render() { + let src = sunset(); + let s = DevelopSettings::default(); + assert_eq!(deep(&src, &s, OutputDepth::F32Hdr), deep(&src, &s, OutputDepth::F32Linear)); +} + +#[test] +fn sdr_renders_of_an_hdr_edit_use_the_sdr_rendition() { + let src = sunset(); + let req = RenderRequest::fit(240, 240); + // neutral offsets: identical to the edit with HDR off + let plain = render(&src, &raw(), &DevelopSettings::default(), &req); + let on = render(&src, &raw(), &hdr_on(3.0), &req); + assert_eq!(plain.image.data, on.image.data); + // offsets apply to every SDR render + let mut s = hdr_on(3.0); + s.hdr.sdr_brightness = 50.0; + s.hdr.sdr_highlights = -40.0; + let brighter = render(&src, &raw(), &s, &req); + let expect = render(&src, &raw(), &s.sdr_rendition(), &req); + assert_eq!(brighter.image.data, expect.image.data); + assert_ne!(brighter.image.data, plain.image.data); + let r = s.sdr_rendition(); + assert!(!r.hdr.enabled && (r.light.exposure - 0.5).abs() < 1e-9 && r.light.highlights == -40.0); +} + +#[test] +fn visualize_hdr_paints_only_what_rises_above_white() { + use crate::Overlay; + use crate::visualize::HDR_BANDS; + let src = sunset(); + let req = RenderRequest { overlay: Overlay::HdrRange, ..RenderRequest::fit(240, 240) }; + let banded = |p: &[u8; 4]| HDR_BANDS.iter().any(|b| (0..3).all(|k| (p[k] as i32 - b[k] as i32).abs() < 70) && p[0] > p[2] + 40); + let on = render(&src, &raw(), &hdr_on(3.0), &req); + let n = on.image.data.iter().filter(|p| banded(p)).count(); + assert!(n > 0 && n < on.image.data.len() / 3, "{n} banded pixels"); + // the rest is grey + assert!(on.image.data.iter().filter(|p| !banded(p)).all(|p| p[0] == p[1] && p[1] == p[2])); + // HDR off: the overlay draws nothing + let off = render(&src, &raw(), &DevelopSettings::default(), &req); + let plain = render(&src, &raw(), &DevelopSettings::default(), &RenderRequest::fit(240, 240)); + assert_eq!(off.image.data, plain.image.data); +} diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs index 1a1fcc201..f9602e996 100644 --- a/crates/pipeline/src/tone.rs +++ b/crates/pipeline/src/tone.rs @@ -67,6 +67,11 @@ impl CameraTone { &self.chroma } + /// The (scene, display) luminance knots, in increasing order. + pub fn knots(&self) -> &[[f32; 2]; 32] { + &self.knots + } + pub fn apply(&self, y: f32) -> f32 { if !y.is_finite() || y <= 0.0 { return 0.0; @@ -87,6 +92,9 @@ impl CameraTone { } } +/// Scene EV over grey where [`ToneMap::hdr`] starts lifting highlights above the SDR curve. +const HDR_FROM_EV: f32 = 1.5; + #[derive(Clone, Debug)] pub struct ToneMap { lut: Vec, @@ -108,6 +116,16 @@ impl ToneMap { } /// `contrast`, `whites`, `blacks` in −100..100 (Lightroom slider units). pub fn new(contrast: f64, whites: f64, blacks: f64) -> ToneMap { + ToneMap::hdr(contrast, whites, blacks, 1.0) + } + + /// [`ToneMap::new`] for an HDR render reaching `peak` (linear, SDR white = 1; 1 = SDR exactly). + /// Below about half of SDR white the curve is the SDR one; above, the highlights gain up to + /// `peak − 1` more along a smoothstep in scene EV, from 1.5 EV over grey to one stop past the + /// white point moved up by the headroom, so the result stays monotone, never exceeds `peak`, + /// and reaches it there (within the table's range). + pub fn hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap { + let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 }; let c = (contrast / 100.0) as f32; let slope = if c >= 0.0 { 1.0 + 0.55 * c } else { 1.0 + 0.4 * c }; // White point: scene luminance (after contrast) that maps to display 1.0. @@ -133,7 +151,11 @@ impl ToneMap { let k = b * 0.03; o = k + (1.0 - k) * o; } - o.clamp(0.0, 1.0) + if peak > 1.0 { + let top = (white_ev + peak.log2() + 1.0).max(HDR_FROM_EV + 0.5); + o += (peak - 1.0) * smooth(HDR_FROM_EV, top, ev * slope); + } + o.clamp(0.0, peak) }) .collect(); ToneMap { lut, chroma: NO_CHROMA } @@ -141,6 +163,14 @@ impl ToneMap { /// Tone map for display-referred sources: identity at neutral settings. pub fn display(contrast: f64, whites: f64, blacks: f64) -> ToneMap { + ToneMap::display_hdr(contrast, whites, blacks, 1.0) + } + + /// [`ToneMap::display`] for an HDR render reaching `peak`: values above SDR white (an HDR + /// source, e.g. a decoded gain-map image) pass through and roll off softly towards `peak` + /// instead of clipping at 1; SDR sources (≤ 1) render as with [`ToneMap::display`]. + pub fn display_hdr(contrast: f64, whites: f64, blacks: f64, peak: f32) -> ToneMap { + let peak = if peak.is_finite() { peak.max(1.0) } else { 1.0 }; let c = (contrast / 100.0) as f32; let w = (whites / 100.0) as f32; let b = (blacks / 100.0) as f32; @@ -163,11 +193,15 @@ impl ToneMap { } let mut o = p.max(0.0).powf(2.2); // short shoulder: slope 1 at 0.95, reaching 1.0 at 1.05 - if o > 0.95 { + if peak > 1.0 && o > 0.95 { + // exponential shoulder: slope 1 at 0.95, asymptote at `peak` + let r = peak - 0.95; + o = 0.95 + r * (1.0 - (-(o - 0.95) / r).exp()); + } else if o > 0.95 { let d = (o - 0.95).min(0.1); o = 0.95 + d - d * d / 0.2; } - o.clamp(0.0, 1.0) + o.clamp(0.0, peak) }) .collect(); ToneMap { lut, chroma: NO_CHROMA } @@ -291,6 +325,51 @@ mod tests { } } + #[test] + fn hdr_is_sdr_at_peak_one_and_reaches_peak() { + for (c, w, b) in [(0.0, 0.0, 0.0), (100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (50.0, -30.0, -40.0)] { + assert_eq!(ToneMap::hdr(c, w, b, 1.0).lut(), ToneMap::new(c, w, b).lut()); + for peak in [2.0f32, 8.0, 32.0] { + let sdr = ToneMap::new(c, w, b); + let t = ToneMap::hdr(c, w, b, peak); + let mut prev = -1.0; + for i in 0..2400 { + let y = 1e-5 * 1.01f32.powi(i); + let o = t.apply(y); + assert!((0.0..=peak).contains(&o), "{c} {w} {b} {peak} at {y}: {o}"); + assert!(o >= prev - 1e-6, "{c} {w} {b} {peak} at {y}: {o} < {prev}"); + prev = o; + if y <= GREY * 2f32.powf(0.9) { + assert!((o - sdr.apply(y)).abs() < 1e-6, "midtones differ at {y}"); + } + } + assert!(t.apply(4.0) > sdr.apply(4.0)); + if (c, w, b) == (0.0, 0.0, 0.0) { + assert!(t.apply(GREY * 2f32.powf(LUT_MAX_EV)) > peak * 0.99, "{peak}"); + } + } + } + // a broken peak falls back to SDR + assert_eq!(ToneMap::hdr(0.0, 0.0, 0.0, f32::NAN).lut(), ToneMap::new(0.0, 0.0, 0.0).lut()); + } + + #[test] + fn display_hdr_passes_hdr_sources_and_keeps_sdr() { + assert_eq!(ToneMap::display_hdr(20.0, 10.0, -5.0, 1.0).lut(), ToneMap::display(20.0, 10.0, -5.0).lut()); + let t = ToneMap::display_hdr(0.0, 0.0, 0.0, 8.0); + for i in 1..=90 { + let y = i as f32 / 100.0; + assert!((t.apply(y) - y).abs() < 2e-3, "{y} -> {}", t.apply(y)); + } + let mut prev = 0.0; + for i in 1..2000 { + let o = t.apply(i as f32 / 100.0); + assert!(o >= prev - 1e-6 && o <= 8.0); + prev = o; + } + assert!(t.apply(3.0) > 2.0 && t.apply(3.0) < 3.0); + } + #[test] fn display_identity_and_monotone() { let t = ToneMap::display(0.0, 0.0, 0.0); diff --git a/crates/pipeline/src/visualize.rs b/crates/pipeline/src/visualize.rs index 99e45449a..fc3d8390f 100644 --- a/crates/pipeline/src/visualize.rs +++ b/crates/pipeline/src/visualize.rs @@ -33,8 +33,14 @@ pub enum Overlay { /// The evaluated alpha of mask `id` (a [`lightcraft_develop::Mask`] id), drawn as `view` in /// `color` at `opacity` (0..100; the colour views only). Mask { id: u16, view: MaskView, color: [u8; 3], opacity: u8 }, + /// Visualize HDR (photos edited in HDR): the SDR image dimmed to grey, with every pixel the HDR + /// rendition puts above SDR white painted by how far above it is ([`HDR_BANDS`]). + HdrRange, } +/// [`Overlay::HdrRange`] colours (sRGB) for 0–1, 1–2, 2–3 and ≥ 3 stops above SDR white. +pub const HDR_BANDS: [[u8; 3]; 4] = [[255, 214, 92], [255, 150, 40], [235, 64, 52], [214, 60, 210]]; + /// How [`Overlay::Mask`] draws the mask. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MaskView { @@ -114,6 +120,7 @@ impl Overlay { Overlay::PointColorRange(i) => (1, i as f64), Overlay::Spots(t) => (2, t as f64), Overlay::Mask { id, view, color, opacity } => (3, pack_mask(id, view, color, opacity) as f64), + Overlay::HdrRange => (4, 0.0), } } @@ -123,6 +130,7 @@ impl Overlay { 1 => Overlay::PointColorRange(v as u8), 2 => Overlay::Spots(v.clamp(0.0, 100.0) as u8), 3 if v.is_finite() && v >= 0.0 => unpack_mask(v as u64), + 4 => Overlay::HdrRange, _ => Overlay::None, } } @@ -134,6 +142,7 @@ impl Overlay { Overlay::PointColorRange(i) => 0x1000 + i as u64, Overlay::Spots(t) => 0x2000 + t as u64, Overlay::Mask { id, view, color, opacity } => 3 << 60 | pack_mask(id, view, color, opacity), + Overlay::HdrRange => 0x4000, } } @@ -162,7 +171,8 @@ pub fn adjust_settings(o: Overlay, s: &mut Cow<'_, DevelopSettings>) { /// an [`Overlay::Mask`] shows, at the image's size (see [`Overlay::mask`]). pub fn apply(img: &mut Rgba8, o: Overlay, plan: &Plan<'_>, mask: Option<&Plane>) { match o { - Overlay::None => {} + // drawn by the renderer from the HDR rendition ([`hdr_range`]) + Overlay::None | Overlay::HdrRange => {} Overlay::PointColorRange(i) => { if let Some(p) = plan.settings.point_colors.get(i as usize) { point_color_range(img, &PointK::new(p)); @@ -245,6 +255,34 @@ fn point_color_range(img: &mut Rgba8, k: &PointK) { }); } +/// Draw [`Overlay::HdrRange`] on `img` (the SDR render) from `hdr` (the HDR render of the same +/// request, float linear with luminance weights `luma`): grey below SDR white, band colours above. +pub fn hdr_range(img: &mut Rgba8, hdr: &crate::DeepImage, luma: [f32; 3]) { + let crate::DeepSamples::F32(v) = &hdr.samples else { return }; + if (hdr.width, hdr.height) != (img.width, img.height) { + return; + } + let w = img.width; + for_rows(&mut img.data, w, |y, row| { + for (x, p) in row.iter_mut().enumerate() { + let i = (y * w + x) * 3; + let Some(c) = v.get(i..i + 3) else { continue }; + let lum = luma[0] * c[0] + luma[1] * c[1] + luma[2] * c[2]; + let grey = (0.2126 * p[0] as f32 + 0.7152 * p[1] as f32 + 0.0722 * p[2] as f32) * 0.55; + if lum > 1.0 { + let stops = lum.log2(); + let band = HDR_BANDS[(stops as usize).min(HDR_BANDS.len() - 1)]; + for k in 0..3 { + p[k] = (grey * 0.25 + band[k] as f32 * 0.75).round().clamp(0.0, 255.0) as u8; + } + } else { + let g = grey.round().clamp(0.0, 255.0) as u8; + (p[0], p[1], p[2]) = (g, g, g); + } + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/preview/src/pool.rs b/crates/preview/src/pool.rs index a0ea185f2..4dbd35661 100644 --- a/crates/preview/src/pool.rs +++ b/crates/preview/src/pool.rs @@ -7,11 +7,16 @@ //! //! Native: a fixed set of worker threads, started on first use. wasm32 (no threads): the host //! calls [`JobPool::run_inline`] once per frame. +//! +//! No worker outlives its pool by more than a deadline (issue #620: a render still inside the GPU +//! driver while the process exits crashes there): [`JobPool::shutdown`] and dropping the pool +//! discard the queued jobs and wait, bounded, for the running ones. use std::hash::Hash; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; pub type Job = Box R + Send>; @@ -27,8 +32,16 @@ struct Shared { queue: Mutex>>, cv: Condvar, shutdown: AtomicBool, + /// The worker threads that have not ended yet, and the signal that one did. + #[cfg(not(target_arch = "wasm32"))] + live: Mutex>, + #[cfg(not(target_arch = "wasm32"))] + ended: Condvar, } +/// How long dropping a pool waits for the jobs still running (see [`JobPool::shutdown`]). +const DROP_WAIT: Duration = Duration::from_secs(2); + /// A finished job. pub struct Done { pub slot: S, @@ -47,6 +60,10 @@ pub struct JobPool { threads: usize, started: bool, seq: u64, + #[cfg(not(target_arch = "wasm32"))] + workers: Vec>, + /// [`JobPool::shutdown`] ran: dropping the pool doesn't wait a second time. + stopped: bool, } impl JobPool { @@ -54,12 +71,23 @@ impl JobPool { pub fn new(threads: usize) -> Self { let (tx, rx) = channel(); JobPool { - shared: Arc::new(Shared { queue: Mutex::new(Vec::new()), cv: Condvar::new(), shutdown: AtomicBool::new(false) }), + shared: Arc::new(Shared { + queue: Mutex::new(Vec::new()), + cv: Condvar::new(), + shutdown: AtomicBool::new(false), + #[cfg(not(target_arch = "wasm32"))] + live: Mutex::new(Vec::new()), + #[cfg(not(target_arch = "wasm32"))] + ended: Condvar::new(), + }), tx, rx, threads, started: false, seq: 0, + #[cfg(not(target_arch = "wasm32"))] + workers: Vec::new(), + stopped: false, } } @@ -81,24 +109,69 @@ impl JobPool { } self.started = true; #[cfg(not(target_arch = "wasm32"))] - for i in 0..self.threads { - let shared = self.shared.clone(); - let tx = self.tx.clone(); - let _ = std::thread::Builder::new().name(format!("lc-job-{i}")).spawn(move || { - while let Some(e) = take(&shared, true) { - let t0 = std::time::Instant::now(); - let result = (e.job)(); - let done = Done { slot: e.slot, key: e.key, result, ms: t0.elapsed().as_secs_f64() * 1000.0 }; - if tx.send(done).is_err() { - break; + { + // held while spawning: a worker that ends at once still finds its id listed + let mut live = self.shared.live.lock().unwrap_or_else(|e| e.into_inner()); + for i in 0..self.threads { + let shared = self.shared.clone(); + let tx = self.tx.clone(); + let spawned = std::thread::Builder::new().name(format!("lc-job-{i}")).spawn(move || { + let _ended = Ended(&shared); + while let Some(e) = take(&shared, true) { + let t0 = std::time::Instant::now(); + let result = (e.job)(); + let done = Done { slot: e.slot, key: e.key, result, ms: t0.elapsed().as_secs_f64() * 1000.0 }; + if tx.send(done).is_err() { + break; + } } + }); + match spawned { + Ok(h) => { + live.push(h.thread().id()); + self.workers.push(h); + } + Err(e) => log::error!("job pool: worker {i} could not start: {e}"), } - }); + } + } + } + + /// Stop the pool: queued jobs are dropped, no new one starts, and the call waits up to + /// `timeout` for the jobs that are running. Returns whether every worker has ended; one that + /// hasn't is left to finish on its own. For owners that are about to end the process, where + /// dropping the pool may come too late or never (`std::process::exit`). + pub fn shutdown(&mut self, timeout: Duration) -> bool { + self.stopped = true; + #[cfg(not(target_arch = "wasm32"))] + { + stop(&self.shared, &mut self.workers, timeout) + } + #[cfg(target_arch = "wasm32")] + { + let _ = timeout; + close(&self.shared); + true + } + } + + /// Worker threads that have not ended (0 before the first job, and on wasm). + pub fn live_workers(&self) -> usize { + #[cfg(not(target_arch = "wasm32"))] + { + self.shared.live.lock().map(|l| l.len()).unwrap_or(0) + } + #[cfg(target_arch = "wasm32")] + { + 0 } } /// Queue `job` for `slot`, replacing a queued (not yet running) job for the same slot. pub fn submit(&mut self, slot: S, key: u64, priority: u32, job: Job) { + if self.stopped { + return; // nothing runs it any more + } self.start(); self.seq += 1; let (lock, cv) = (&self.shared.queue, &self.shared.cv); @@ -172,9 +245,66 @@ fn take(shared: &Shared, wait: bool) -> Option> { } } -impl Drop for JobPool { +/// Takes a worker off the live list when its thread ends (also when a job panicked). +#[cfg(not(target_arch = "wasm32"))] +struct Ended<'a, S, R>(&'a Shared); + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for Ended<'_, S, R> { fn drop(&mut self) { - self.shared.shutdown.store(true, Ordering::Relaxed); - self.shared.cv.notify_all(); + let me = std::thread::current().id(); + self.0.live.lock().unwrap_or_else(|e| e.into_inner()).retain(|id| *id != me); + self.0.ended.notify_all(); + } +} + +/// Tell the workers to stop and take the queued jobs away from them. +fn close(shared: &Shared) { + // the flag changes under the queue lock: a worker is either before its check or waiting + let queued = { + let mut q = shared.queue.lock().unwrap_or_else(|e| e.into_inner()); + shared.shutdown.store(true, Ordering::Relaxed); + std::mem::take(&mut *q) + }; + shared.cv.notify_all(); + drop(queued); // outside the lock: a job may own a lot +} + +/// [`JobPool::shutdown`]: wait up to `timeout` for the workers to end, without holding the queue +/// lock, and never for the calling thread (a pool dropped by one of its own jobs). +#[cfg(not(target_arch = "wasm32"))] +fn stop(shared: &Shared, workers: &mut Vec>, timeout: Duration) -> bool { + close(shared); + let me = std::thread::current().id(); + let t0 = std::time::Instant::now(); + let mut live = shared.live.lock().unwrap_or_else(|e| e.into_inner()); + while live.iter().any(|id| *id != me) { + let Some(left) = timeout.checked_sub(t0.elapsed()).filter(|d| !d.is_zero()) else { break }; + live = shared.ended.wait_timeout(live, left).unwrap_or_else(|e| e.into_inner()).0; + } + let running: Vec = live.iter().copied().filter(|id| *id != me).collect(); + drop(live); + for h in workers.drain(..) { + let id = h.thread().id(); + // a worker off the list is past its last job: joining it only waits for the thread to unwind + if id != me && !running.contains(&id) { + let _ = h.join(); + } + } + if !running.is_empty() { + log::warn!("job pool: {} job(s) still running after {timeout:?}; not waiting for them", running.len()); + } + running.is_empty() +} + +impl Drop for JobPool { + fn drop(&mut self) { + if self.stopped { + return; + } + #[cfg(not(target_arch = "wasm32"))] + stop(&self.shared, &mut self.workers, DROP_WAIT); + #[cfg(target_arch = "wasm32")] + close(&self.shared); } } diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 3d6340032..d733ad829 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -201,6 +201,119 @@ fn pool_runs_inline() { assert_eq!(p.try_recv().map(|d| d.result), Some(1)); } +/// A pool with one worker that is inside a job until the returned sender is used or dropped. +fn pool_with_a_running_job() -> (JobPool, std::sync::mpsc::Sender<()>) { + let mut p: JobPool = JobPool::new(1); + let (started, has_started) = std::sync::mpsc::channel(); + let (release, released) = std::sync::mpsc::channel::<()>(); + p.submit( + 1, + 0, + 1, + Box::new(move || { + let _ = started.send(()); + let _ = released.recv_timeout(std::time::Duration::from_secs(60)); + }), + ); + has_started.recv().unwrap(); + (p, release) +} + +#[test] +fn pool_drop_waits_for_running_job() { + // issue #620: a job that outlives its pool can still be inside the GPU driver when the process exits + use std::sync::atomic::{AtomicBool, Ordering}; + let mut p: JobPool = JobPool::new(1); + let (started, has_started) = std::sync::mpsc::channel(); + let (finished, queued_ran) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false))); + let f = finished.clone(); + p.submit( + 1, + 0, + 9, + Box::new(move || { + let _ = started.send(()); + std::thread::sleep(std::time::Duration::from_millis(200)); + f.store(true, Ordering::SeqCst); + }), + ); + has_started.recv().unwrap(); + let q = queued_ran.clone(); + p.submit(2, 0, 1, Box::new(move || q.store(true, Ordering::SeqCst))); + drop(p); + assert!(finished.load(Ordering::SeqCst), "drop returned while the job was still running"); + assert!(!queued_ran.load(Ordering::SeqCst), "a job that had not started is discarded"); +} + +#[test] +fn pool_shutdown_reports_workers_and_drop_does_not_wait_again() { + let (mut p, release) = pool_with_a_running_job(); + assert_eq!(p.live_workers(), 1); + let t0 = std::time::Instant::now(); + assert!(!p.shutdown(std::time::Duration::from_millis(100)), "the job is still running"); + let waited = t0.elapsed(); + assert!(waited >= std::time::Duration::from_millis(100) && waited < std::time::Duration::from_secs(30), "{waited:?}"); + assert_eq!(p.live_workers(), 1); + // stopped: nothing new is queued, and the job that ends is noticed + p.submit(2, 0, 1, Box::new(|| ())); + assert_eq!(p.queued(), 0); + drop(release); + assert!(p.shutdown(std::time::Duration::from_secs(30))); + assert_eq!(p.live_workers(), 0); + + let (mut p, _release) = pool_with_a_running_job(); + assert!(!p.shutdown(std::time::Duration::ZERO)); + let t0 = std::time::Instant::now(); + drop(p); + assert!(t0.elapsed() < std::time::Duration::from_secs(1), "dropped after a shutdown: no second wait"); +} + +#[test] +fn pool_drop_is_bounded_when_a_job_never_ends() { + // a job blocked on something its owner holds must not hang the owner (it would hang quitting) + let (p, _release) = pool_with_a_running_job(); + let t0 = std::time::Instant::now(); + drop(p); + let waited = t0.elapsed(); + assert!(waited >= std::time::Duration::from_secs(1) && waited < std::time::Duration::from_secs(30), "{waited:?}"); +} + +#[test] +fn pool_stops_after_a_job_panicked_and_when_dropped_by_its_own_job() { + // a worker that unwound is not waited for + let mut p: JobPool = JobPool::new(1); + p.submit(1, 0, 1, Box::new(|| panic!("a job panics (expected in this test)"))); + while p.live_workers() > 0 { + std::thread::yield_now(); + } + assert!(p.shutdown(std::time::Duration::from_secs(30))); + + // the last owner of a pool is one of its jobs: the worker must not wait for (or join) itself + let slot: Arc>>> = Arc::new(std::sync::Mutex::new(None)); + let (dropped, has_dropped) = std::sync::mpsc::channel(); + let mut p: JobPool = JobPool::new(1); + let s = slot.clone(); + p.submit( + 1, + 0, + 1, + Box::new(move || { + let own = loop { + if let Some(p) = s.lock().unwrap().take() { + break p; + } + std::thread::yield_now(); + }; + let t0 = std::time::Instant::now(); + drop(own); + let _ = dropped.send(t0.elapsed()); + }), + ); + *slot.lock().unwrap() = Some(p); + let waited = has_dropped.recv_timeout(std::time::Duration::from_secs(30)).unwrap(); + assert!(waited < std::time::Duration::from_secs(1), "{waited:?}"); +} + #[test] fn disk_cache_never_touches_foreign_files() { // issue #98: a library opened on a folder that already has a `thumbs/` folder diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index fedc033b5..27e30002f 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -19,6 +19,76 @@ pub(crate) fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { .max_by_key(|i| i.u64(t::IMAGE_WIDTH).unwrap_or(0).saturating_mul(i.u64(t::IMAGE_LENGTH).unwrap_or(0))) } +/// Undo the DNG 1.7 row/column interleave, which reorders the stored raster without changing its +/// size: with `RowInterleaveFactor = n` the rows are stored as `n` consecutive fields, field `g` +/// holding rows `g, g+n, g+2n, …` of the image (and likewise for columns). +/// +/// The tag counts fields, not a divisor of the raster — see [`field_map`] — so a dimension the +/// factor does not divide is decoded, with the trailing fields short, rather than refused. +/// +/// Left alone, a file like this decodes as `n × m` scaled-down copies of the scene, one per +/// field, with the CFA pattern then read across permuted samples — so it is not merely misplaced, +/// it is the wrong colour as well (`RowInterleaveFactor = ColumnInterleaveFactor = 2` is what DNG +/// 1.7 JPEG XL files from Adobe's converter carry). +fn deinterleave(data: &mut RawData, w: usize, h: usize, cpp: usize, rows: usize, cols: usize) -> Result<()> { + if rows <= 1 && cols <= 1 { + return Ok(()); + } + // The spec sets no bound on the value (it is only "the number of interleaved fields"); this + // caps the field table we build and the permutation, and rejects a hostile tag. + if !(1..=16).contains(&rows) || !(1..=16).contains(&cols) { + return Err(RawError::Unsupported(format!("interleave factors {rows} × {cols}"))); + } + let (map_r, map_c) = (field_map(h, rows), field_map(w, cols)); + let dest = |r: usize, c: usize| map_r[r] * w + map_c[c]; + match data { + RawData::U16(v) => permute(v, w, h, cpp, dest), + RawData::F32(v) => permute(v, w, h, cpp, dest), + } +} + +/// The stored-line → image-line map for one axis: a factor of `n` splits `len` lines into `n` +/// interleaved fields, field `g` holding lines `g, g+n, g+2n, …`. The fields are stored +/// consecutively, so field `g` starts at stored line `offs[g]` and its `k`-th line is image line +/// `g + n*k`. Field sizes follow from the round-robin split — field `g` has `ceil((len - g) / n)` +/// lines — so when `n` does not divide `len` the trailing fields are short, and when `n` exceeds +/// `len` the trailing fields are empty. +fn field_map(len: usize, n: usize) -> Vec { + let mut offs = Vec::with_capacity(n + 1); + let mut acc = 0usize; + for g in 0..=n { + offs.push(acc); + if g < n && g < len { + acc += (len - 1 - g) / n + 1; + } + } + let mut map = vec![0usize; len]; + for g in 0..n { + for (k, i) in (offs[g]..offs[g + 1]).enumerate() { + map[i] = g + n * k; + } + } + map +} + +/// Move every `cpp`-sample pixel of `v` from its stored position to `dest(r, c)`, the image +/// position it belongs at. The permutation is not its own inverse, so it needs a destination. +fn permute usize>(v: &mut Vec, w: usize, h: usize, cpp: usize, dest: F) -> Result<()> { + let n = w.checked_mul(h).and_then(|n| n.checked_mul(cpp)).ok_or_else(|| RawError::Corrupt("image too large".into()))?; + if v.len() != n { + return Err(RawError::Corrupt("mosaic size does not match the image".into())); + } + let mut out = vec![T::default(); n]; + for r in 0..h { + for c in 0..w { + let (from, to) = ((r * w + c) * cpp, dest(r, c) * cpp); + out[to..to + cpp].copy_from_slice(&v[from..from + cpp]); + } + } + *v = out; + Ok(()) +} + fn mat3(v: Option>) -> Option { let v = v?; if v.len() != 9 || v.iter().any(|x| !x.is_finite()) { @@ -125,6 +195,15 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result Result Result Vec { + let (gh, gw) = (h / rows, w / cols); + let mut out = vec![0u16; w * h]; + for r in 0..h { + for c in 0..w { + out[((r % rows) * gh + r / rows) * w + (c % cols) * gw + c / cols] = image[r * w + c]; + } + } + out + } + + fn samples(d: &RawData) -> Vec { + match d { + RawData::U16(v) => v.clone(), + RawData::F32(_) => Vec::new(), + } + } + + /// The image `image` laid out in stored order, given the image line each stored row and column + /// holds. Written out by hand from the tag's plain reading, so it does not share a formula with + /// the code under test. + fn store(image: &[u16], w: usize, rows_img: &[usize], cols_img: &[usize]) -> Vec { + let mut out = vec![0u16; image.len()]; + for (sr, &ir) in rows_img.iter().enumerate() { + for (sc, &ic) in cols_img.iter().enumerate() { + out[sr * w + sc] = image[ir * w + ic]; + } + } + out + } + + #[test] + fn deinterleaves_an_interleaved_raster() { + // 3×3 rather than 2×2 on purpose: the 2×2 permutation is its own inverse, so a 2×2 fixture + // would pass even with the direction of the permutation reversed. This is the shape a DNG + // 1.7 file caught in the wild carried (JPEG XL, `RowInterleaveFactor`/`ColumnInterleaveFactor` + // both 2) — its real-file ground truth is that it renders bit-identical to its + // non-interleaved twin. + let (w, h, rows, cols) = (12, 9, 3, 3); + let image: Vec = (0..w * h).map(|i| i as u16).collect(); + let mut data = RawData::U16(interleave(&image, w, h, rows, cols)); + assert_ne!(samples(&data), image, "the fixture has to actually be permuted"); + deinterleave(&mut data, w, h, 1, rows, cols).unwrap(); + assert_eq!(samples(&data), image); + + // 2×2 as well, the shape Adobe's converter writes. + let (w2, h2) = (12, 8); + let image2: Vec = (0..w2 * h2).map(|i| i as u16).collect(); + let mut two = RawData::U16(interleave(&image2, w2, h2, 2, 2)); + deinterleave(&mut two, w2, h2, 1, 2, 2).unwrap(); + assert_eq!(samples(&two), image2); + } + + #[test] + fn deinterleave_pins_the_field_direction() { + // Hand-computed ground truth for the tag's plain reading ("field g holds lines g, g+n, + // g+2n, …"). The 3×3 test above round-trips against a helper written from the same reading + // of the tag, so it fixes consistency but not the direction; and a 2×2 fixture cannot tell + // the two directions apart, being its own inverse. These maps are written out literally so + // a reversed permutation fails here. 3 row fields of h = 9 start at stored rows 0, 3, 6 and + // hold image rows [0,3,6,1,4,7,2,5,8]; 2 column fields of w = 4 give [0,2,1,3]. + let (w, h) = (4, 9); + let image: Vec = (0..(w * h) as u16).collect(); + let mut data = RawData::U16(store(&image, w, &[0, 3, 6, 1, 4, 7, 2, 5, 8], &[0, 2, 1, 3])); + assert_ne!(samples(&data), image, "the fixture has to actually be permuted"); + deinterleave(&mut data, w, h, 1, 3, 2).unwrap(); + assert_eq!(samples(&data), image); + } + + #[test] + fn deinterleaves_a_raster_the_factor_does_not_divide() { + // The spec fixes the number of fields, not that the size is a multiple of it: h = 7 over 3 + // row fields is 3 + 2 + 2 (the round-robin split), so field 0 starts at stored row 0 holding + // image rows 0, 3, 6; field 1 at stored row 3 holding 1, 4; field 2 at stored row 5 holding + // 2, 5. Columns: w = 5 over 2 fields is 3 + 2 → image columns [0,2,4,1,3]. + let (w, h) = (5, 7); + let image: Vec = (0..(w * h) as u16).collect(); + let mut data = RawData::U16(store(&image, w, &[0, 3, 6, 1, 4, 2, 5], &[0, 2, 4, 1, 3])); + assert_ne!(samples(&data), image, "the fixture has to actually be permuted"); + deinterleave(&mut data, w, h, 1, 3, 2).unwrap(); + assert_eq!(samples(&data), image); + } + + #[test] + fn deinterleave_is_a_no_op_at_one_and_refuses_a_hostile_factor() { + let image: Vec = (0..48).collect(); + let mut plain = RawData::U16(image.clone()); + deinterleave(&mut plain, 8, 6, 1, 1, 1).unwrap(); + assert_eq!(samples(&plain), image); + + // A hostile factor is an error, not a scrambled image. + let mut hostile = RawData::U16(image); + assert!(deinterleave(&mut hostile, 8, 6, 1, 1 << 20, 1).is_err()); + } +} diff --git a/crates/raw/src/highlight.rs b/crates/raw/src/highlight.rs index 1731cb254..7fc056600 100644 --- a/crates/raw/src/highlight.rs +++ b/crates/raw/src/highlight.rs @@ -2,9 +2,22 @@ //! //! - [`clip_neutral`]: white-balance-aware clipping so that sensor-clipped areas render neutral instead of //! magenta/cyan (each channel limited to the smallest white-balanced clip level). -//! - [`reconstruct`]: where only some channels are clipped, rebuild them from the unclipped channels using the -//! local chromaticity of nearby unclipped pixels (diffused into the clipped region with a coarse-to-fine -//! normalised-convolution fill). Fully clipped pixels become neutral at the brightest plausible level. +//! - [`reconstruct`] / [`reconstruct_with`]: where only some channels are clipped, rebuild them from the +//! unclipped channels using the chromaticity of reliable unclipped content nearby (bright, away from edges and +//! from the clipped pixels themselves; diffused into the clipped region coarse to fine) unless both the pixel's +//! own channels and the clipped surface's colour just below its clip (the rim) contradict it, and neutral where +//! there is none within reach. Right past the rim, the rim's colour carries on. Fully clipped pixels become +//! neutral at the brightest plausible level; partly clipped ones fade into it on their way from the first channel +//! clipping to the last, pale ones from the start. +//! +//! Why the care: the white-balance gains push clipped channels apart (green clips first, red and blue keep +//! rising), so whatever colour the rebuilt pixels carry is hidden at Neutral only by the tone map's roll-off to +//! white. Any darkening (Highlights, negative Exposure or Whites) brings it out. Three things used to leak colour +//! in: the chromaticity of whatever unclipped pixel happened to be nearest (dark foliage, purple and green +//! fringes along branches, pixels demosaiced from clipped neighbours); a colour model that does not render the +//! camera's white-balanced neutral as neutral (a look fitted to the camera's JPEG, a camera profile); and, in a +//! pale partly clipped sky, the colour of another reliable surface (bright foliage) and the sky's own faint +//! colour, which darkening and a camera profile turned teal. use lightcraft_raster::Rgb32f; use rayon::prelude::*; @@ -20,119 +33,374 @@ pub fn clip_neutral(img: &mut Rgb32f, wb: [f32; 3], clip: f32) { } } -/// Fill `values` (per-pixel vectors) where `valid` is false from valid neighbours, coarse to fine. -fn fill_invalid(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) { - if w == 0 || h == 0 || valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { - return; - } - // Build a pyramid of weighted means, fill each level from the next coarser one. - let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[0f32; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; - { - let (values, valid) = (&*values, &*valid); - downsample(w, h, &mut cv, &mut cval, |i| valid[i].then(|| values[i])); - } - if cw * ch < w * h { - fill_invalid(cw, ch, &mut cv, &mut cval); - } else { - // cannot shrink further (1×1): nothing valid anywhere handled above - return; - } - values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..w { - if !okrow[x] { - vrow[x] = upsample(&cv, cw, ch, x, y); - okrow[x] = true; - } - } - }); +/// Unclipped pixels lend their chromaticity in proportion to how close they come to clipping (their largest +/// channel, relative to the clip level): from nothing below `SOURCE_LO` to fully above `SOURCE_HI`. Dark content +/// (foliage, branches, buildings against a clipped sky) says nothing about the sky's colour. +const SOURCE_LO: f32 = 0.25; +const SOURCE_HI: f32 = 0.6; +/// Sources on a strong edge (a 3 × 3 half-resolution neighbour brighter or darker by this many stops) are +/// left out, fading from `EDGE_LO` to `EDGE_HI`: edge pixels mix two surfaces and carry lens fringes. +const EDGE_LO: f32 = 0.15; +const EDGE_HI: f32 = 0.4; +/// Source cells within this many half-resolution cells of a clipped pixel are left out: their channels were +/// demosaiced partly from clipped samples. (At least 1: the same neighbourhood tells which cells clipped +/// pixels sample.) +const MARGIN: usize = 1; +/// Weight of the coarser estimate against a cell's own sources when the pyramid is pushed back down (in units +/// of source density: a cell half covered by full-weight sources counts 0.5). +const PRIOR: f32 = 0.01; +/// The rebuilt colour fades to neutral where reliable content covers less than `SUPPORT` of the surroundings at +/// every scale up to `REACH` of the long edge (so previews and exports agree), gradually, as the coverage of the +/// widest one falls: a clipped sky between bare branches is better white than the colour of a river far away. +const REACH: f32 = 1.0 / 4.0; +const SUPPORT: f32 = 0.05; +/// An unclipped channel above `FLOOR_LO` of its clip level counts more and more as the clip floor it is about to +/// become (see [`reconstruct_with`]). +const FLOOR_LO: f32 = 0.75; +/// A clipped pixel takes its estimated colour (reliable colour around, or the neutral guess) only as far as it is +/// brighter (or otherwise unlike) the unclipped pixels nearest its clip: the "rim", unclipped pixels above `RIM_LO` +/// of the clip level, weighted towards the clip, the clipped surface's own colour just below it. Up to `NEAR_RIM` +/// stops past the rim (the rim is an average, a little below the clip) it is rebuilt with the rim's colour, and +/// over the next `DEPTH` stops it goes over to the estimate, so a surface runs into clipping without a step +/// whatever its colour and whatever lent the estimate; well past it, or where the rim is another surface (its +/// colour differs; a clipped channel differs by at least as much as it lies above the rim's), the estimate applies. +const RIM_LO: f32 = 0.85; +const NEAR_RIM: f32 = 0.03; +const DEPTH: f32 = 0.2; +/// Reliable colour counts less and less as both the pixel's own channels (see [`contradiction`]) and the rim's +/// colour contradict it, by `AGREE.0` to `AGREE.1` stops: it is another surface's, like bright foliage below a pale +/// sky, whose green would turn the sky teal. (Either alone can be wrong: a pixel's own channels are noisy and +/// can't speak for the channels that clipped, and the rim can be another surface, like a white window frame +/// around a bright view.) +const AGREE: (f32, f32) = (0.15, 0.4); +/// On the way from the first channel clipping to the last, a partly clipped pixel's brightness goes over to the +/// clipped neutral's in the second half, and so does the colour of a strongly coloured one (saturation relative to +/// the white above `PALE.1`). A pale one's colour (below `PALE.0`) goes over from the start: the few per cent of +/// colour left in a pale sky once a channel clipped is as much the estimate's error as the sky's, and darkening +/// shows it. +const PALE: (f32, f32) = (0.2, 0.4); + +#[inline] +fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 { + let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) } -/// One pyramid step: each `2 × 2` block of a `w × h` level becomes the mean of its valid -/// entries (`at(i)`: the value at index `i` when valid) in `cv`, `cval` (`⌈w/2⌉ × ⌈h/2⌉`). -fn downsample(w: usize, h: usize, cv: &mut [[f32; 3]], cval: &mut [bool], at: impl Fn(usize) -> Option<[f32; 3]> + Sync) { - let cw = w.div_ceil(2); - cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| { +/// One level of a pyramid, `w × h` cells of `N - 1` values and a weight. Pulled: per cell the weighted sums of +/// the values and the weight, as densities (per pixel of the cell). Pushed: the estimated values, and still the +/// weight. The chromaticity pyramid holds red and blue chromaticity, the rim pyramid white-balanced RGB. +struct Level { + w: usize, + h: usize, + cells: Vec<[f32; N]>, +} + +/// Bilinear sample of a level at the centre of pixel `(x, y)` of a grid `scale` times finer. +#[inline] +fn sample(l: &Level, scale: f32, x: usize, y: usize) -> [f32; N] { + let (cv, cw, ch) = (&l.cells, l.w, l.h); + let fx = ((x as f32 + 0.5) / scale - 0.5).clamp(0.0, cw.saturating_sub(1) as f32); + let fy = ((y as f32 + 0.5) / scale - 0.5).clamp(0.0, ch.saturating_sub(1) as f32); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(cw.saturating_sub(1)), (y0 + 1).min(ch.saturating_sub(1))); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let g = |xx: usize, yy: usize| cv.get(yy * cw + xx).copied().unwrap_or([0.0; N]); + let (a, b, c, d) = (g(x0, y0), g(x1, y0), g(x0, y1), g(x1, y1)); + std::array::from_fn(|k| { + let top = a[k] + (b[k] - a[k]) * tx; + let bot = c[k] + (d[k] - c[k]) * tx; + top + (bot - top) * ty + }) +} + +/// The half-resolution source level of `img`: every `2 × 2` block's source-weighted chromaticity sums, with +/// blocks near clipped pixels or on strong edges left out. Also returns, per cell, whether a clipped pixel lies +/// in its row within [`MARGIN`] cells (the vertical half of that test is left to the readers). +fn sources(img: &Rgb32f, wb: [f32; 3], clip: f32) -> (Level<3>, Vec) { + let (w, h) = (img.width, img.height); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + // per cell: [red sum, blue sum, weight] densities, luminance, whether it holds a clipped pixel + let mut cells = vec![[0f32; 3]; cw * ch]; + let mut lum = vec![0f32; cw * ch]; + let mut near = vec![false; cw * ch]; + cells.par_chunks_mut(cw).zip(lum.par_chunks_mut(cw)).zip(near.par_chunks_mut(cw)).enumerate().for_each(|(y, ((crow, lrow), nrow))| { + let mut touched = vec![false; cw]; + let row = |r: usize| img.data.get(r * w..(r + 1) * w).unwrap_or(&[]); + let (r0, r1) = (row(2 * y), row(2 * y + 1)); + let lo = SOURCE_LO * clip; for x in 0..cw { - let (mut s, mut n) = ([0f32; 3], 0); - for dy in 0..2 { - for dx in 0..2 { - let (sx, sy) = (2 * x + dx, 2 * y + dy); - if sx < w - && sy < h - && let Some(v) = at(sy * w + sx) - { - for c in 0..3 { - s[c] += v[c]; - } - n += 1; - } + let (mut a, mut sum, mut n, mut hit) = ([0f32; 3], 0f32, 0usize, false); + let span = 2 * x..(2 * x + 2).min(w); + for p in r0.get(span.clone()).unwrap_or(&[]).iter().chain(r1.get(span).unwrap_or(&[])) { + let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + let s = q[0].max(0.0) + q[1].max(0.0) + q[2].max(0.0); + sum += s; + n += 1; + let top = p[0].max(p[1]).max(p[2]); + if top >= clip { + hit = true; + continue; + } + if top > lo && s > 1e-4 { + let b = smoothstep(SOURCE_LO, SOURCE_HI, top / clip) / s; + a[0] += b * q[0].max(0.0); + a[1] += b * q[2].max(0.0); + a[2] += b * s; } } - if n > 0 { - vrow[x] = s.map(|v| v / n as f32); - okrow[x] = true; - } + let k = 1.0 / n.max(1) as f32; + crow[x] = a.map(|v| v * k); + lrow[x] = (sum * k).max(1e-6); + touched[x] = hit; + } + for (x, nr) in nrow.iter_mut().enumerate() { + *nr = touched.get(x.saturating_sub(MARGIN)..=(x + MARGIN).min(cw - 1)).is_some_and(|t| t.iter().any(|&t| t)); } }); + // leave out cells near clipped pixels and on strong edges (luminance ratios to the neighbours) + let (lum, near_ref) = (&lum, &near); + let (edge_lo, edge_hi) = (EDGE_LO.exp2(), EDGE_HI.exp2()); + cells.par_chunks_mut(cw).enumerate().for_each(|(y, row)| { + let (ya, yb) = (y.saturating_sub(MARGIN), (y + MARGIN).min(ch - 1)); + let (ea, eb) = (y.saturating_sub(1), (y + 1).min(ch - 1)); + for (x, cell) in row.iter_mut().enumerate() { + if cell[2] <= 0.0 { + continue; + } + if (ya..=yb).any(|yy| near_ref.get(yy * cw + x).copied().unwrap_or(false)) { + *cell = [0.0; 3]; + continue; + } + let l = lum.get(y * cw + x).copied().unwrap_or(1.0); + let (xa, xb) = (x.saturating_sub(1), (x + 1).min(cw - 1)); + let (mut lo, mut hi) = (l, l); + for yy in ea..=eb { + for &v in lum.get(yy * cw + xa..=yy * cw + xb).unwrap_or(&[]) { + lo = lo.min(v); + hi = hi.max(v); + } + } + let keep = 1.0 - smoothstep(edge_lo, edge_hi, (hi / l).max(l / lo)); + *cell = cell.map(|v| v * keep); + } + }); + (Level { w: cw, h: ch, cells }, near) } -/// Bilinear sample of the `cw × ch` coarse level at fine pixel `(x, y)` (twice the resolution). -fn upsample(cv: &[[f32; 3]], cw: usize, ch: usize, x: usize, y: usize) -> [f32; 3] { - let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); - let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); - let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1)); - let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); - let g = |xx: usize, yy: usize| cv[yy * cw + xx]; - let mut v = [0f32; 3]; - for c in 0..3 { - let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx; - let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; - v[c] = top + (bot - top) * ty; +/// The quarter-resolution rim level of `img`: every `4 × 4` block's white-balanced RGB of its unclipped pixels +/// above [`RIM_LO`] of the clip level, weighted (steeply) towards the clip, as densities. Pixels next to clipped +/// ones (`margin`, by half-resolution cell) are left out, like the colour sources: they were demosaiced partly from +/// clipped samples, and lens fringes along clipped content would pass for its colour. +fn rim(img: &Rgb32f, wb: [f32; 3], clip: f32, margin: &(dyn Fn(usize, usize) -> bool + Sync)) -> Level<4> { + let (w, h) = (img.width, img.height); + let (cw, ch) = (w.div_ceil(4), h.div_ceil(4)); + let mut cells = vec![[0f32; 4]; cw * ch]; + cells.par_chunks_mut(cw).enumerate().for_each(|(y, row)| { + for (x, cell) in row.iter_mut().enumerate() { + let (mut a, mut n) = ([0f32; 4], 0usize); + for sy in 4 * y..(4 * y + 4).min(h) { + for (i, p) in img.data.get(sy * w + 4 * x..sy * w + (4 * x + 4).min(w)).unwrap_or(&[]).iter().enumerate() { + n += 1; + let top = p[0].max(p[1]).max(p[2]) / clip; + if !(RIM_LO..1.0).contains(&top) || margin((4 * x + i) / 2, sy / 2) { + continue; + } + let k = ((top - RIM_LO) / (1.0 - RIM_LO)).powi(4); + for c in 0..3 { + a[c] += k * p[c] * wb[c]; + } + a[3] += k; + } + } + *cell = a.map(|v| v / n.max(1) as f32); + } + }); + Level { w: cw, h: ch, cells } +} + +/// The next coarser level: each `2 × 2` block's mean densities. +fn pull(l: &Level) -> Level { + let (w, h) = (l.w.div_ceil(2), l.h.div_ceil(2)); + let mut cells = vec![[0f32; N]; w * h]; + cells.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, cell) in row.iter_mut().enumerate() { + let (mut s, mut n) = ([0f32; N], 0usize); + for (sx, sy) in [(2 * x, 2 * y), (2 * x + 1, 2 * y), (2 * x, 2 * y + 1), (2 * x + 1, 2 * y + 1)] { + if let Some(c) = (sx < l.w && sy < l.h).then(|| l.cells.get(sy * l.w + sx)).flatten() { + for k in 0..N { + s[k] += c[k]; + } + n += 1; + } + } + *cell = s.map(|v| v / n.max(1) as f32); + } + }); + Level { w, h, cells } +} + +/// The estimated values of the cells of `levels[0]` that `needed` marks (all of them when `None`), and of every +/// cell of the coarser levels: each level's own sources blended with the next coarser estimate. The top level is +/// its own sources' weighted mean (normalised: however sparse they are in a large empty picture, the empty part +/// never pulls the estimate anywhere), and `empty` (at zero weight) when it has none at all. +fn push(levels: &mut [Level], empty: [f32; N], needed: Option<&(dyn Fn(usize, usize) -> bool + Sync)>) { + let estimate = |cell: [f32; N], prior: [f32; N]| -> [f32; N] { + let weight = cell[N - 1]; + let d = weight + PRIOR; + std::array::from_fn(|k| if k + 1 == N { weight } else { (cell[k] + PRIOR * prior[k]) / d }) + }; + let Some(last) = levels.last_mut() else { return }; + last.cells.iter_mut().for_each(|c| { + let weight = c[N - 1]; + *c = if weight > 0.0 { std::array::from_fn(|k| if k + 1 == N { weight } else { c[k] / weight }) } else { empty }; + }); + for i in (0..levels.len().saturating_sub(1)).rev() { + let (fine, coarse) = levels.split_at_mut(i + 1); + let (Some(fine), Some(coarse)) = (fine.last_mut(), coarse.first()) else { continue }; + let w = fine.w; + let needed = needed.filter(|_| i == 0); + fine.cells.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, cell) in row.iter_mut().enumerate() { + if needed.is_some_and(|f| !f(x, y)) { + continue; + } + *cell = estimate(*cell, sample(coarse, 2.0, x, y)); + } + }); } - v } -/// White-balanced chromaticity of an unclipped pixel (`None`: clipped or too dark to tell). -#[inline] -fn chroma_of(p: &[f32; 3], wb: [f32; 3], clip: f32) -> Option<[f32; 3]> { - if p[0] >= clip || p[1] >= clip || p[2] >= clip { +/// How many stops two chromaticities differ by in any channel ratio. +fn unlike(a: [f32; 3], b: [f32; 3]) -> f32 { + let ratio = |i: usize, j: usize| (a[i].max(1e-6) * b[j].max(1e-6)) / (a[j].max(1e-6) * b[i].max(1e-6)); + [(0, 1), (0, 2), (1, 2)].iter().map(|&(i, j)| ratio(i, j).log2().abs()).fold(0.0f32, f32::max) +} + +/// How far channel `c` of a pixel (sensor values `p`, clipped channels `cl`) only bounds its true value from below: +/// fully once clipped, and increasingly from `FLOOR_LO` of the clip on, so that nothing compared with it jumps where +/// it clips. +fn bound(p: [f32; 3], cl: [bool; 3], clip: f32, c: usize) -> f32 { + if cl[c] { 1.0 } else { smoothstep(FLOOR_LO, 1.0, p[c] / clip) } +} + +/// How many stops the channels of a partly clipped pixel (sensor values `p`, white-balanced `q`, clipped channels +/// `cl`) contradict chromaticity `r`: how far the levels they imply spread, where a clipped channel's only bounds the +/// level from below (see [`bound`]). 0 for the pixel's own colour, whatever its level. +fn contradiction(p: [f32; 3], q: [f32; 3], cl: [bool; 3], r: [f32; 3], clip: f32) -> f32 { + let (mut lo, mut hi) = (f32::INFINITY, 0f32); + for c in 0..3 { + let level = q[c] / r[c].max(1e-3); + hi = hi.max(level); + let w = bound(p, cl, clip, c); + if r[c] > 1e-3 && w < 1.0 { + lo = lo.min(level / (1.0 - w)); + } + } + if hi > 0.0 && lo > 0.0 && lo.is_finite() { (hi / lo).log2().max(0.0) } else { 0.0 } +} + +/// The white-balanced colour of a partly clipped pixel (sensor values `p`, white-balanced `q`, clipped channels +/// `cl`) whose colour is estimated at chromaticity `r`: clipped channels rebuilt, unclipped ones kept. `None` +/// when no unclipped channel can tell the level. +fn rebuild(p: [f32; 3], q: [f32; 3], cl: [bool; 3], r: [f32; 3], clip: f32, max_level: f32) -> Option<[f32; 3]> { + // The white-balanced channel sum the unclipped channels imply: their mean, except that a channel about to clip + // counts more and more as the floor it is about to become (continuous with the neighbours where it has). + let (mut sum, mut near_clip, mut k) = (0f32, 0f32, 0usize); + for c in 0..3 { + if !cl[c] && r[c] > 1e-3 { + let s = q[c] / r[c]; + sum += s; + near_clip = near_clip.max(s * smoothstep(FLOOR_LO, 1.0, p[c] / clip)); + k += 1; + } + } + if k == 0 { return None; } - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let s = q[0] + q[1] + q[2]; - (s > 1e-4).then(|| q.map(|v| v.max(0.0) / s)) + // Two clipped channels must not be left at their clip levels: those differ by the white-balance gains (green + // clips first), and the pair would show as magenta or red. They keep the estimate's ratio instead, at the level + // that lifts each to its clip (no channel above the clipped neutral), and so does a channel about to clip (the + // same floor, so nothing jumps where it clips). + let floor = (0..3).filter(|&c| cl[c]).map(|c| q[c] / r[c].max(1e-3)).fold(0.0f32, f32::max); + let top = r[0].max(r[1]).max(r[2]).max(1e-3); + let sum = (sum / k as f32).max(near_clip.max(floor).min(max_level / top)); + Some(std::array::from_fn(|c| if cl[c] { q[c].max(sum * r[c]) } else { q[c] })) +} + +/// Reconstruct partially clipped channels, with a neutral that is neutral in camera RGB. See +/// [`reconstruct_with`]. +pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { + reconstruct_with(img, wb, clip, [1.0; 3]) } /// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly -/// below 1.0, e.g. 0.99), `wb` the white-balance multipliers that will be applied afterwards. -/// Returns the number of pixels that had at least one clipped channel. -/// -/// The chromaticity field is diffused coarse to fine from the unclipped pixels. A clipped pixel is -/// never valid at full resolution, so its chromaticity is always the bilinear sample of the -/// half-resolution level: we start the pyramid there, straight from the image, and never build -/// the full-resolution field (same result, a quarter of the memory and far less work). -pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { +/// below 1.0, e.g. 0.99), `wb` the white-balance multipliers that will be applied afterwards, `white` the +/// white-balanced camera RGB that the colour model applied afterwards renders neutral (`[1, 1, 1]` for a model +/// that maps the white-balanced neutral to neutral), scaled to the brightness that `[1, 1, 1]` renders at. +/// Fully clipped pixels become `white` times the brightest plausible level; partly clipped ones take the +/// chromaticity of reliable unclipped content nearby, and that of `white` where there is none. +/// Returns the number of pixels that had at least one clipped channel; the others are left exactly as they were. +pub fn reconstruct_with(img: &mut Rgb32f, wb: [f32; 3], clip: f32, white: [f32; 3]) -> usize { let (w, h) = (img.width, img.height); let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip; let count = img.data.par_iter().filter(|p| is_clipped(p)).count(); - if count == 0 { + if count == 0 || w == 0 || h == 0 { return 0; } - // chromaticity (white-balanced ratios to the channel sum) of unclipped pixels, at half resolution + // a white far from the camera's neutral (or not a number) is a broken colour model: keep the camera's + let plausible = white.iter().all(|v| v.is_finite() && (1.0 / 16.0..=16.0).contains(v)); + let white = if plausible { white } else { [1.0; 3] }; + let total = white[0] + white[1] + white[2]; + let neutral = [white[0] / total, white[1] / total, white[2] / total]; + // A clipped pixel is never a source, so its chromaticity is always the bilinear sample of the + // half-resolution estimate: the pyramid starts there, straight from the image. + let (first, near) = sources(img, wb, clip); + let mut levels = vec![first]; + while let Some(l) = levels.last().filter(|l| l.w > 1 || l.h > 1) { + let next = pull(l); + levels.push(next); + } + // a clipped pixel samples the cells within one of its own, which are within one of a clipped cell let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[1f32 / 3.0; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; - { - let data = &img.data; - downsample(w, h, &mut cv, &mut cval, |i| chroma_of(&data[i], wb, clip)); + let needed = |x: usize, y: usize| (y.saturating_sub(1)..=(y + 1).min(ch - 1)).any(|yy| near.get(yy * cw + x).copied().unwrap_or(false)); + push(&mut levels, [neutral[0], neutral[2], 0.0], Some(&needed)); + // How much reliable content lies around each needed cell: its density at any scale up to [`REACH`] (level + // i's cells span 2^i cells of level 0). Near reliable content some fine level is dense with it; far from it + // only the widest one may still hold a little, and the trust fades with that. Kept in the third entry of the + // level 0 estimates, which clipped pixels sample with their colour. + let reach = REACH * w.max(h) as f32 / 2.0; + let widest = (0..levels.len()).find(|&i| (1u64 << i.min(62)) as f32 >= reach).unwrap_or(levels.len().saturating_sub(1)); + if let Some((first, coarser)) = levels.split_first_mut() { + let support: Vec<(&Level<3>, f32)> = coarser.iter().take(widest).enumerate().map(|(i, l)| (l, (1u64 << (i + 1).min(62)) as f32)).collect(); + let fw = first.w; + first.cells.par_chunks_mut(fw).enumerate().for_each(|(y, row)| { + for (x, cell) in row.iter_mut().enumerate() { + if !needed(x, y) { + continue; + } + let mut t = 0f32; + for &(l, scale) in &support { + t = t.max(smoothstep(0.0, SUPPORT, sample(l, scale, x, y)[2])); + if t >= 1.0 { + break; + } + } + cell[2] = t; + } + }); } - if cw * ch < w * h { - fill_invalid(cw, ch, &mut cv, &mut cval); - } - let any_valid = cw * ch < w * h && cval.iter().any(|&v| v); + let Some(field) = levels.first() else { return count }; + // the rim (level 0 of its pyramid holds the estimate), if there is one anywhere at all + let rims = { + let mut levels = vec![rim(img, wb, clip, &needed)]; + while let Some(l) = levels.last().filter(|l| l.w > 1 || l.h > 1) { + let next = pull(l); + levels.push(next); + } + push(&mut levels, [0.0; 4], None); + Some(levels).filter(|levels| levels.last().is_some_and(|top| top.cells.iter().any(|c| c[3] > 0.0))) + }; + let rim_field = rims.as_ref().and_then(|l| l.first()); let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip; img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { for (x, px) in row.iter_mut().enumerate() { @@ -141,27 +409,81 @@ pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { if !cl.iter().any(|&b| b) { continue; } - let r = if any_valid { upsample(&cv, cw, ch, x, y) } else { [1f32 / 3.0; 3] }; let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - // estimate the white-balanced channel sum from the unclipped channels - let (mut sum, mut k) = (0f32, 0usize); - for c in 0..3 { - if !cl[c] && r[c] > 1e-3 { - sum += q[c] / r[c]; - k += 1; + // rebuilt with the colour of the reliable content around, and with neutral, blended by how much of it + // there is and how well the pixel agrees with it (blending the results, not the colours, keeps the + // estimate consistent with the pixel's own unclipped channels all the way) + let e = sample(field, 2.0, x, y); + let borrowed = [e[0], 1.0 - e[0] - e[1], e[1]]; + let edge = rim_field.map(|rim| sample(rim, 4.0, x, y)); + let mut trust = e[2].clamp(0.0, 1.0); + if trust > 0.0 { + let mut off = contradiction(p, q, cl, borrowed, clip); + if off > AGREE.0 { + off = off.min(edge.map_or(0.0, |edge| { + let s = edge[0] + edge[1] + edge[2]; + if s > 1e-6 { unlike([edge[0] / s, edge[1] / s, edge[2] / s], borrowed) } else { 0.0 } + })); } + trust *= 1.0 - smoothstep(AGREE.0, AGREE.1, off); } - let mut out = q; - if k == 0 { - let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); - out = [v; 3]; - } else { - let sum = sum / k as f32; - for c in 0..3 { - if cl[c] { - out[c] = q[c].max(sum * r[c]); - } + // either applies only as far as the pixel lies past the rim nearest its clip (in any channel); nearer, the + // pixel is rebuilt with the rim's colour + let at_rim = edge.and_then(|edge| { + // stops between pixel and rim in each channel; a clipped channel's true value lies at least as far + // above the rim's as its clip, and no further below (see [`bound`]) + let ratio = (0..3) + .map(|c| { + let (a, b) = (q[c].max(1e-12), edge[c].max(1e-12)); + if a >= b { + return a / b; + } + match bound(p, cl, clip, c) { + w if w <= 0.0 => b / a, + w if w >= 1.0 => 1.0, + w => (b / a).powf(1.0 - w), + } + }) + .fold(1.0f32, f32::max); + let past = ratio.log2().min(64.0); + let k = smoothstep(NEAR_RIM, NEAR_RIM + DEPTH, past); + let s = edge[0] + edge[1] + edge[2]; + (k < 1.0 && s > 1e-6).then(|| rebuild(p, q, cl, [edge[0] / s, edge[1] / s, edge[2] / s], clip, max_level)).flatten().map(|a| (a, k)) + }); + let past_rim = |b: [f32; 3]| -> [f32; 3] { + let Some((a, k)) = at_rim else { return b }; + std::array::from_fn(|c| a[c] + (b[c] - a[c]) * k) + }; + let local = (trust > 0.0).then(|| rebuild(p, q, cl, borrowed, clip, max_level)).flatten().map(past_rim); + let plain = (trust < 1.0).then(|| rebuild(p, q, cl, neutral, clip, max_level)).flatten().map(past_rim); + let mut out = match (local, plain) { + (Some(a), Some(b)) => std::array::from_fn(|c| b[c] + (a[c] - b[c]) * trust), + (Some(a), None) | (None, Some(a)) => a, + (None, None) => { + let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); + white.map(|c| c * v) } + }; + // Fade into the fully clipped neutral on the way from the first channel clipping (no change: continuous + // with the unclipped neighbours) to the last one clipping (all of it: continuous with the fully clipped + // ones). The way is measured in stops: how far the rebuilt clipped channels lie above their clip, + // against how far the lowest unclipped channel still lies below its own. + let above = (0..3).filter(|&c| cl[c]).map(|c| (out[c] / q[c].max(1e-9)).max(1.0).log2()).fold(0.0f32, f32::max); + let lowest = (0..3).filter(|&c| !cl[c]).map(|c| p[c]).fold(f32::MAX, f32::min); + let below = (clip / lowest.max(1e-6)).log2().max(0.0); + let way = if above > 0.0 && lowest < clip { above / (above + below) } else { 0.0 }; + // The brightness goes over in the second half of the way, and so does the colour of a strongly coloured + // pixel; a pale one's goes over from the start ([`PALE`]). + let u = [out[0] / white[0], out[1] / white[1], out[2] / white[2]]; + let (lo, hi) = (u[0].min(u[1]).min(u[2]), u[0].max(u[1]).max(u[2])); + let saturation = if hi > 0.0 && hi.is_finite() { 1.0 - lo.max(0.0) / hi } else { 1.0 }; + let start = 0.5 * smoothstep(PALE.0, PALE.1, saturation); + let (t_level, t_colour) = (smoothstep(0.5, 1.0, way), smoothstep(start, start + 0.5, way)); + if t_colour > 0.0 { + let level = (out[0] + out[1] + out[2]) / total; + let full = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); + let v = level + (full - level) * t_level; + out = std::array::from_fn(|c| out[c] + (white[c] * v - out[c]) * t_colour); } *px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; } @@ -173,154 +495,228 @@ pub fn reconstruct(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { mod tests { use super::*; - // The previous full-resolution implementation, kept as the reference for `reconstruct`. - /// Fill `values` (per-pixel vectors) where `valid` is false from valid neighbours, coarse to fine. - fn fill_invalid_ref(w: usize, h: usize, values: &mut [[f32; 3]], valid: &mut [bool]) { - if w == 0 || h == 0 || valid.iter().all(|&v| v) || !valid.iter().any(|&v| v) { - return; - } - // Build a pyramid of weighted means, fill each level from the next coarser one. - let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); - let mut cv = vec![[0f32; 3]; cw * ch]; - let mut cval = vec![false; cw * ch]; - { - let (values, valid) = (&*values, &*valid); - cv.par_chunks_mut(cw).zip(cval.par_chunks_mut(cw)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..cw { - let (mut s, mut n) = ([0f32; 3], 0); - for dy in 0..2 { - for dx in 0..2 { - let (sx, sy) = (2 * x + dx, 2 * y + dy); - if sx < w && sy < h && valid[sy * w + sx] { - for c in 0..3 { - s[c] += values[sy * w + sx][c]; - } - n += 1; - } - } - } - if n > 0 { - vrow[x] = s.map(|v| v / n as f32); - okrow[x] = true; - } - } - }); - } - if cw * ch < w * h { - fill_invalid_ref(cw, ch, &mut cv, &mut cval); - } else { - // cannot shrink further (1×1): nothing valid anywhere handled above - return; - } - values.par_chunks_mut(w).zip(valid.par_chunks_mut(w)).enumerate().for_each(|(y, (vrow, okrow))| { - for x in 0..w { - if !okrow[x] { - // bilinear upsample of the coarse level - let fx = ((x as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (cw - 1) as f32); - let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (ch - 1) as f32); - let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); - let (x1, y1) = ((x0 + 1).min(cw - 1), (y0 + 1).min(ch - 1)); - let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); - let g = |xx: usize, yy: usize| cv[yy * cw + xx]; - let mut v = [0f32; 3]; - for c in 0..3 { - let top = g(x0, y0)[c] + (g(x1, y0)[c] - g(x0, y0)[c]) * tx; - let bot = g(x0, y1)[c] + (g(x1, y1)[c] - g(x0, y1)[c]) * tx; - v[c] = top + (bot - top) * ty; - } - vrow[x] = v; - okrow[x] = true; - } - } - }); + /// Daylight-like multipliers: green clips first, red and blue keep rising. + const WB: [f32; 3] = [2.5, 1.0, 1.6]; + + fn balanced(p: [f32; 3]) -> [f32; 3] { + [p[0] * WB[0], p[1] * WB[1], p[2] * WB[2]] } - /// Reconstruct partially clipped channels. `clip` is the sensor clip level in normalised units (use slightly - /// below 1.0, e.g. 0.99), `wb` the white-balance multipliers that will be applied afterwards. - /// Returns the number of pixels that had at least one clipped channel. - fn reconstruct_ref(img: &mut Rgb32f, wb: [f32; 3], clip: f32) -> usize { - let (w, h) = (img.width, img.height); - let n = w * h; - let is_clipped = |p: &[f32; 3]| p[0] >= clip || p[1] >= clip || p[2] >= clip; - let count = img.data.par_iter().filter(|p| is_clipped(p)).count(); - if count == 0 { - return 0; - } - // chromaticity (white-balanced ratios to the channel mean) of unclipped pixels - let mut chroma = vec![[1f32 / 3.0; 3]; n]; - let mut valid = vec![false; n]; - chroma.par_iter_mut().zip(valid.par_iter_mut()).zip(img.data.par_iter()).for_each(|((r, v), p)| { - if is_clipped(p) { - return; - } - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let s = q[0] + q[1] + q[2]; - if s > 1e-4 { - *r = q.map(|v| v.max(0.0) / s); - *v = true; - } - }); - fill_invalid_ref(w, h, &mut chroma, &mut valid); - let max_level = wb.iter().cloned().fold(0.0f32, f32::max) * clip; - img.data.par_iter_mut().zip(chroma.par_iter()).for_each(|(px, r)| { - let p = *px; - let cl = [p[0] >= clip, p[1] >= clip, p[2] >= clip]; - if !cl.iter().any(|&b| b) { - return; - } - let q = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - // estimate the white-balanced channel sum from the unclipped channels - let (mut sum, mut k) = (0f32, 0usize); - for c in 0..3 { - if !cl[c] && r[c] > 1e-3 { - sum += q[c] / r[c]; - k += 1; - } - } - let mut out = q; - if k == 0 { - let v = q.iter().cloned().fold(0.0f32, f32::max).max(max_level); - out = [v; 3]; + fn sensor(q: [f32; 3]) -> [f32; 3] { + [q[0] / WB[0], q[1] / WB[1], q[2] / WB[2]].map(|v| v.min(1.0)) + } + + fn chroma(q: [f32; 3]) -> [f32; 3] { + let s = q[0] + q[1] + q[2]; + q.map(|v| v / s) + } + + /// A pale blue sky whose green channel clipped, an unclipped strip of the same sky along the top, and a dark + /// branch with purple fringes on both sides standing in the clipped part (white-balanced sky colour returned). + fn branch_scene() -> (Rgb32f, [f32; 3]) { + let sky = [1.10, 1.25, 1.45]; + let img = Rgb32f::from_fn(192, 128, |x, y| { + sensor(if y < 24 { + sky.map(|v| v * 0.7) + } else if (90..100).contains(&x) && y >= 40 { + [0.06, 0.05, 0.03] + } else if (x == 89 || x == 100) && y >= 40 { + [0.75, 0.25, 0.96] } else { - let sum = sum / k as f32; - for c in 0..3 { - if cl[c] { - out[c] = q[c].max(sum * r[c]); - } - } + sky + }) + }); + (img, sky) + } + + /// How far chromaticity `got` lies off the way from `from` to neutral (largest channel difference to the + /// nearest point of that way). + fn off_the_way_to_neutral(got: [f32; 3], from: [f32; 3]) -> f32 { + let d: [f32; 3] = std::array::from_fn(|c| 1.0 / 3.0 - from[c]); + let len = d.iter().map(|v| v * v).sum::(); + let t = if len > 0.0 { ((0..3).map(|c| (got[c] - from[c]) * d[c]).sum::() / len).clamp(0.0, 1.0) } else { 0.0 }; + (0..3).map(|c| (got[c] - (from[c] + d[c] * t)).abs()).fold(0.0, f32::max) + } + + /// Issue #523: the clipped sky beside a branch took the colour of the branch's fringes and of the branch + /// itself (magenta, green), which any darkening of the highlights then showed. Beside the branch it is now the + /// clipped sky away from it: the sky's colour, on its way to neutral. + #[test] + fn fringes_and_dark_branches_do_not_tint_a_clipped_sky() { + let (mut img, sky) = branch_scene(); + assert!(reconstruct(&mut img, WB, 0.99) > 0); + let mut worst = 0f32; + for y in 40..128 { + let away = chroma(balanced(img.get(30, y))); + assert!(away == chroma(balanced(img.get(170, y))), "row {y}"); + let off = off_the_way_to_neutral(away, chroma(sky)); + assert!(off < 0.005, "row {y}: the clipped sky {away:?} is not the sky's colour on its way to neutral ({off})"); + for x in [84, 86, 88, 101, 103, 105] { + let got = chroma(balanced(img.get(x, y))); + worst = worst.max((0..3).map(|c| (got[c] - away[c]).abs()).fold(0.0, f32::max)); } - *px = [out[0] / wb[0], out[1] / wb[1], out[2] / wb[2]]; - }); - count + } + assert!(worst < 0.01, "clipped sky beside the branch is off the sky away from it by {worst}"); } - /// `cargo test --release -p lightcraft-raw --lib -- --ignored bench_reconstruct --nocapture` + /// A warm light with red and green clipped: left at their clip levels the pair reads red-magenta (the white + /// balance multiplies red's clip level by 2.5, green's by 1). #[test] - #[ignore] - fn bench_reconstruct() { - let img = Rgb32f::from_fn(6000, 4000, |x, y| { - let v = 0.4 + 0.8 * ((x as f32 * 0.003).sin() * (y as f32 * 0.002).cos()).abs(); - [v.min(1.0), (v * 0.7).min(1.0), (v * 0.5).min(1.0)] + fn two_clipped_channels_are_not_left_at_their_clip_levels() { + let light = [3.5, 2.6, 0.8]; + let mut img = Rgb32f::from_fn(64, 64, |x, y| { + let d = ((x as f32 - 32.0).powi(2) + (y as f32 - 32.0).powi(2)).sqrt(); + if d < 20.0 { sensor(light) } else { [0.02; 3] } }); - let wb = [2.0, 1.0, 1.5]; - let time = |f: &dyn Fn(&mut Rgb32f) -> usize| { - (0..3) - .map(|_| { - let mut i = img.clone(); - let t = std::time::Instant::now(); - f(&mut i); - t.elapsed().as_secs_f64() * 1e3 - }) - .fold(f64::MAX, f64::min) - }; - let new = time(&|i| reconstruct(i, wb, 0.99)); - let old = time(&|i| reconstruct_ref(i, wb, 0.99)); - eprintln!("reconstruct 24 MP: {new:.0} ms (full-resolution reference: {old:.0} ms)"); + reconstruct(&mut img, WB, 0.99); + let q = balanced(img.get(32, 32)); + assert!((q[2] - 0.8).abs() < 0.1, "the unclipped channel is about kept: {q:?}"); + let ratio = q[1] / q[0]; + assert!((0.6..=1.05).contains(&ratio), "green / red {ratio} ({q:?}): not a warm white"); } - /// The half-resolution start gives exactly the full-resolution algorithm's result. #[test] - fn matches_the_full_resolution_reference() { + fn fully_clipped_pixels_follow_the_colour_model_white() { + let white = [1.03, 0.98, 1.06]; + let mut img = Rgb32f::filled(8, 8, [1.0; 3]); + img.data[0] = [0.3, 0.6, 0.4]; + reconstruct_with(&mut img, WB, 0.99, white); + let q = balanced(img.get(4, 4)); + let k = q[1] / white[1]; + assert!((0..3).all(|c| (q[c] - white[c] * k).abs() < 1e-4 * k), "{q:?} is not along {white:?}"); + assert!(k >= 2.5 * 0.99 - 1e-4, "clipped neutral at least as bright as the highest clip level: {k}"); + assert_eq!(img.data[0], [0.3, 0.6, 0.4]); + // a white that cannot be right falls back to camera neutral, and nothing overflows + for bad in [[f32::NAN, 1.0, 1.0], [2.5e38, 0.74, 0.74], [1.0, 0.0, 1.0], [40.0, 1.0, 1.0]] { + let mut img = Rgb32f::filled(4, 4, [1.0; 3]); + img.data[0] = [1.0, 1.0, 0.5]; + reconstruct_with(&mut img, WB, 0.99, bad); + let q = balanced(img.get(1, 1)); + assert!((q[0] - q[1]).abs() < 1e-4 && (q[2] - q[1]).abs() < 1e-4, "{bad:?}: {q:?}"); + assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{bad:?}"); + } + } + + /// Red rises through its clip level across a sky whose green and blue clipped: the rebuilt colour changes + /// smoothly (no edge where red clips too) and arrives at the clipped neutral. + #[test] + fn rebuilt_colour_is_continuous_as_more_channels_clip() { + let blue = [1.0, 1.3, 1.9]; + let img0 = Rgb32f::from_fn(240, 40, |x, y| { + if y < 8 { + return sensor(blue.map(|v| v * 0.5)); // unclipped sky: the source of colour + } + let level = 0.6 + 2.2 * x as f32 / 239.0; // red at 0.24 to 1.12 of its clip level + sensor([level * WB[0] * 0.4, 1.6, 2.0]) + }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let row: Vec<[f32; 3]> = (0..240).map(|x| chroma(balanced(img.get(x, 30)))).collect(); + let step = row.windows(2).map(|w| (0..3).map(|c| (w[1][c] - w[0][c]).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); + assert!(step < 0.01, "largest chromaticity step between neighbours {step}"); + let end = row[239]; + assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 1e-3), "fully clipped end {end:?}"); + } + + /// A smooth pink ramp (`[0.9k, k, 0.8k]`, k from 0.8 to 1.35) whose green clips first, then red, then blue, + /// in `rows` of a `h`-row picture that is otherwise near black: the rebuilt middle row, and where green first + /// clips on it. + fn rebuilt_ramp(rows: std::ops::Range, h: usize) -> (Vec<[f32; 3]>, usize) { + let (w, k0, k1) = (1600usize, 0.8f32, 1.35f32); + let k = |x: usize| k0 + (k1 - k0) * x as f32 / (w - 1) as f32; + let img0 = Rgb32f::from_fn(w, h, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let y = (rows.start + rows.end) / 2; + let row: Vec<[f32; 3]> = (0..w).map(|x| balanced(img.get(x, y))).collect(); + (row, (0..w).find(|&x| img0.get(x, y)[1] >= 0.99).unwrap_or(0)) + } + + /// No channel changes by more than `at_clip` (relative) where green first clips, nor by 3 % anywhere. + fn assert_continuous(row: &[[f32; 3]], first: usize, at_clip: f32, case: &str) { + // across the first clip the colour carries on as it was (the sensor's own colour, not a fade) + let (a, b) = (row[first - 1], row[first]); + assert!((0..3).all(|c| (b[c] / a[c] - 1.0).abs() < at_clip), "{case}, first clip: {a:?} then {b:?}"); + let worst = row.windows(2).map(|p| (0..3).map(|c| (p[1][c] / p[0][c] - 1.0).abs()).fold(0.0, f32::max)).fold(0.0, f32::max); + // (a smooth ramp moves 0.04 % per pixel here; the rebuilt colour turns faster where it leaves the colour of + // the unclipped part for neutral, but never by a step) + assert!(worst < 0.03, "{case}: largest relative step between neighbours {worst}"); + } + + /// The rebuilt colour has no step anywhere along the ramp: not where green first clips (the neutral fade starts + /// from nothing there), nor where red does, nor where the last channel clips (the fade ends at the fully + /// clipped neutral). + #[test] + fn rebuilt_ramp_is_continuous_from_the_first_clip_to_the_last() { + let (row, first) = rebuilt_ramp(0..32, 32); + assert_continuous(&row, first, 0.002, "full ramp"); + } + + /// The same ramp as a thin line on near black has no reliable colour around (every pixel of it is on a strong + /// edge), so its clipped part falls back to the neutral guess. That guess only applies as far as the line runs + /// past its own unclipped part: no step where it first clips, however thin the line and however much dark + /// background surrounds it. + #[test] + fn thin_line_without_reliable_colour_is_continuous_where_it_clips() { + for height in [32usize, 512, 1024] { + for width in [1usize, 4, 16] { + let top = (height - width) / 2; + let (row, first) = rebuilt_ramp(top..top + width, height); + assert_continuous(&row, first, 0.002, &format!("{width} rows of {height}")); + // and well past the first clip the neutral guess has taken over (green as high as red and blue imply) + let far = row[1500]; + assert!(far[1] > 0.95 * far[2], "{width} rows of {height}: {far:?}"); + } + } + } + + /// Issue #523 (a river photo darkened by Highlights -75): a pale cyan-blue sky (`[1, 1.07, 1.2]` white-balanced) + /// brightening from left to right through the point where green clips (and later blue), above bright + /// yellow-green foliage, with thin dark branches standing in its lower half. Its rebuilt colour used to lean + /// green to teal: the foliage lent its colour to the sky (green rebuilt from it), with a step where green first + /// clips, and the sky kept its own pale colour where Lightroom shows white. + #[test] + fn pale_sky_by_dark_branches_and_foliage_is_not_tinted() { + let sky = [1.0, 1.07, 1.2]; + let (w, h) = (320usize, 96usize); + let k = |x: usize| 0.7 * (1.2 * x as f32 / (w - 1) as f32).exp2(); + let branches = [150usize, 200, 250]; + let on_branch = |x: usize| branches.iter().any(|&b| (b.saturating_sub(1)..=b + 2).contains(&x)); + let img0 = Rgb32f::from_fn(w, h, |x, y| { + sensor(if y >= 72 { + [0.5, 0.6, 0.22] + } else if y >= 40 && branches.iter().any(|&b| x == b || x == b + 1) { + [0.1, 0.09, 0.04] + } else { + sky.map(|v| v * k(x)) + }) + }); + let mut img = img0.clone(); + reconstruct(&mut img, WB, 0.99); + let at = |x: usize, y: usize| chroma(balanced(img.get(x, y))); + let first = (0..w).find(|&x| img0.get(x, 20)[1] >= 0.99).unwrap_or(0); + assert!(first > 0 && first < 150); + let diff = |a: [f32; 3], b: [f32; 3]| (0..3).map(|c| (a[c] - b[c]).abs()).fold(0.0, f32::max); + for y in [20, 50] { + // no step where green clips + let step = diff(at(first - 1, y), at(first, y)); + assert!(step < 0.001, "row {y}: step of {step} where green clips"); + for x in first..w { + // nothing but the sky's colour on its way to neutral (no green from the foliage)... + let off = off_the_way_to_neutral(at(x, y), chroma(sky)); + assert!(on_branch(x) || off < 0.005, "row {y}, x {x}: {:?} is off the sky's way to neutral by {off}", at(x, y)); + // ... the same beside the branches as above them ... + let beside = diff(at(x, y), at(x, 20)); + assert!(on_branch(x) || beside < 0.005, "row {y}, x {x}: {:?} beside a branch, {:?} above", at(x, y), at(x, 20)); + } + // ... and neutral well before the last channel clips (the sky is 0.7 stops past green's clip there) + let end = at(w - 1, y); + assert!(end.iter().all(|v| (v - 1.0 / 3.0).abs() < 0.002), "row {y}: {end:?} at the end"); + } + } + + #[test] + fn unclipped_pixels_are_left_exactly() { let mut seed = 0x2545_f491_4f6c_dd1du64; let mut rnd = move || { seed ^= seed << 13; @@ -328,28 +724,28 @@ mod tests { seed ^= seed << 17; (seed >> 40) as f32 / (1u64 << 24) as f32 }; - for (w, h) in [(1, 1), (1, 2), (3, 1), (7, 5), (64, 48), (129, 77)] { - for density in [0.02f32, 0.3, 0.97] { - // smooth colour with clipped blobs, some dark pixels, some fully clipped ones - let noise: Vec = (0..w * h).map(|_| rnd()).collect(); - let img = Rgb32f::from_fn(w, h, |x, y| { - let t = ((x as f32 * 0.21).sin() + (y as f32 * 0.17).cos()) * 0.5; - let base = [0.5 + 0.4 * t, 0.45 - 0.2 * t, 0.3 + 0.1 * t]; - let r = noise[y * w + x]; - if r < density * 0.3 { - [1.0, 1.0, 1.0] - } else if r < density { - [1.0, base[1] * 1.6, base[2]] - } else if r > 0.995 { - [0.0, 0.0, 0.00001] - } else { - base - } - }); - let wb = [2.1, 1.0, 1.6]; - let (mut a, mut b) = (img.clone(), img.clone()); - assert_eq!(reconstruct(&mut a, wb, 0.99), reconstruct_ref(&mut b, wb, 0.99)); - assert!(a.data == b.data, "{w}×{h} at {density}"); + for (w, h) in [(1, 1), (3, 1), (7, 5), (64, 48), (129, 77)] { + let noise: Vec = (0..w * h).map(|_| rnd()).collect(); + let img = Rgb32f::from_fn(w, h, |x, y| { + let t = ((x as f32 * 0.21).sin() + (y as f32 * 0.17).cos()) * 0.5; + let base = [0.5 + 0.4 * t, 0.45 - 0.2 * t, 0.3 + 0.1 * t]; + match noise[y * w + x] { + r if r < 0.1 => [1.0; 3], + r if r < 0.4 => [1.0, base[1] * 1.6, base[2]], + r if r > 0.995 => [0.0, 0.0, 0.00001], + _ => base, + } + }); + let mut out = img.clone(); + let n = reconstruct_with(&mut out, [2.1, 1.0, 1.6], 0.99, [1.02, 0.99, 1.05]); + let clipped = img.data.iter().filter(|p| p.iter().any(|&v| v >= 0.99)).count(); + assert_eq!(n, clipped); + for (a, b) in img.data.iter().zip(&out.data) { + if a.iter().all(|&v| v < 0.99) { + assert_eq!(a, b, "{w}×{h}"); + } else { + assert!(b.iter().all(|v| v.is_finite() && *v >= 0.0), "{w}×{h}: {b:?}"); + } } } } @@ -368,19 +764,23 @@ mod tests { #[test] fn reconstruct_restores_clipped_channel() { - // a warm gradient whose red channel clips in the bright half - let truth = Rgb32f::from_fn(64, 16, |x, _| { - let v = 0.3 + 1.2 * x as f32 / 63.0; + // a warm gradient whose red channel clips at its bright end + let truth = Rgb32f::from_fn(256, 16, |x, _| { + let v = 0.3 + 1.0 * x as f32 / 255.0; [v * 1.0, v * 0.6, v * 0.35] }); let mut img = truth.map(|p| p.map(|v| v.min(1.0))); - let before: f32 = img.data.iter().zip(&truth.data).map(|(a, b)| (a[0] - b[0]).abs()).sum(); + let first = (0..256).find(|&x| img.get(x, 8)[0] >= 0.999).unwrap(); + // near the unclipped gradient (within the reach of its colour) red is rebuilt from it + let err = |img: &Rgb32f| -> f32 { (first..first + 12).map(|x| (img.get(x, 8)[0] - truth.get(x, 8)[0]).abs()).sum() }; + let before = err(&img); let n = reconstruct(&mut img, [1.0, 1.0, 1.0], 0.999); assert!(n > 0); - let after: f32 = img.data.iter().zip(&truth.data).map(|(a, b)| (a[0] - b[0]).abs()).sum(); + let after = err(&img); assert!(after < before * 0.2, "before {before} after {after}"); // unclipped pixels untouched assert_eq!(img.get(0, 0), truth.get(0, 0)); + assert_eq!(img.get(first - 1, 8), truth.get(first - 1, 8)); } #[test] @@ -394,4 +794,23 @@ mod tests { let mut empty = Rgb32f::new(0, 0); assert_eq!(reconstruct(&mut empty, [1.0; 3], 0.99), 0); } + + /// `cargo test --release -p lightcraft-raw --lib -- --ignored bench_reconstruct --nocapture` + #[test] + #[ignore] + fn bench_reconstruct() { + let img = Rgb32f::from_fn(6000, 4000, |x, y| { + let v = 0.4 + 0.8 * ((x as f32 * 0.003).sin() * (y as f32 * 0.002).cos()).abs(); + [v.min(1.0), (v * 0.7).min(1.0), (v * 0.5).min(1.0)] + }); + let ms = (0..3) + .map(|_| { + let mut i = img.clone(); + let t = std::time::Instant::now(); + reconstruct(&mut i, [2.0, 1.0, 1.5], 0.99); + t.elapsed().as_secs_f64() * 1e3 + }) + .fold(f64::MAX, f64::min); + eprintln!("reconstruct 24 MP: {ms:.0} ms"); + } } diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 7f725090a..4f9821c09 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -16,9 +16,8 @@ //! `jxl` feature, on by default), tiled/stripped, CFA and LinearRaw), //! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer //! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8, -//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed). -//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (Nikon "lossy after split" NEF, -//! compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources +//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed: 16-bit words, 12-bit XZ-2 words, 12-bit 16-byte blocks of the E-300/E-330/E-500). +//! [`embedded_preview`] covers these containers' JPEG previews. Variants we can't decode yet (compressed ORF, CR3 unverified marker families / C-RAW configurations) return [`RawError::Unsupported`]; each vendor module documents its sources //! (public specifications, tag-name documentation, black-box analysis of CC0 samples) and gaps. Non-DNG files carry no //! colour matrix: [`spectral`] has matrices fitted to measured spectral sensitivities for 52 models, and //! otherwise [`color`] falls back to a documented neutral model. The decoders never panic on malformed input. @@ -38,6 +37,7 @@ pub mod ljpeg; pub mod opcodes; mod preview; pub mod profile; +mod saturation; pub mod semantic; pub mod spectral; mod tiffraw; @@ -51,7 +51,7 @@ pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; pub use lightcraft_raster::Rgb32f; pub use opcodes::{Opcode, OpcodeLists}; -pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space}; +pub use preview::{PreviewColorSpace, embedded_preview, embedded_preview_color_space, embedded_preview_dynamic_range_optimized}; pub use semantic::{SemanticMask, semantic_masks}; use lightcraft_color::Xy; @@ -326,7 +326,7 @@ pub(crate) enum Mode { fn decode_with(bytes: &[u8], mode: Mode) -> Result { match probe(bytes).ok_or(RawError::NotRaw)? { - RawFormat::Dng => dng::decode(bytes, mode), + RawFormat::Dng => dng::decode(bytes, mode).map(lift_clipped), RawFormat::Cr2 => vendor::cr2::decode(bytes, mode), RawFormat::Cr3 => vendor::cr3::decode(bytes, mode), RawFormat::Nef | RawFormat::Nrw => vendor::nef::decode(bytes), @@ -335,13 +335,20 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Rw2 => vendor::rw2::decode(bytes, mode), RawFormat::Pef => vendor::pef::decode(bytes, mode), RawFormat::Orf => vendor::orf::decode(bytes, mode), - RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff), + RawFormat::CfaTiff => dng::decode_as(bytes, mode, RawFormat::CfaTiff).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } } +/// DNG writers state a white level the sensor may never reach; samples stuck at the real saturation point are +/// raised to the white level so they count as clipped (see [`RawImage::lift_clipped_samples`]). +fn lift_clipped(mut r: RawImage) -> RawImage { + r.lift_clipped_samples(); + r +} + /// A raw file's description without its samples (see [`probe_info`]). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RawInfo { @@ -944,6 +951,32 @@ mod tests { assert_eq!(probe(&g), None); } + /// A GoPro GPR (a DNG with VC-5 coded raw data, Compression 9) is refused by the full decode with + /// a reason that names the format, whatever its tile bytes hold; the header probe still + /// describes it (so it imports). + #[test] + fn gopro_vc5_dng_is_unsupported_with_a_clear_reason() { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803])); + ifd.set(t::COMPRESSION, Value::Short(vec![9])); + ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0])); + ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] }); + let bytes = write(&[ifd]); + assert_eq!(probe(&bytes), Some(RawFormat::Dng)); + let want = "DNG compression 9 (GoPro VC-5) is not decoded yet"; + let Err(RawError::Unsupported(why)) = decode(&bytes) else { panic!("expected Unsupported from decode") }; + assert_eq!(why, want); + let info = probe_info(&bytes).expect("header probe describes the file"); + assert_eq!((info.width, info.height), (32, 16)); + } + /// Compression 99 (not a registered TIFF value; Leaf MOS tiles) marks a raw even without a CFA tag. #[test] fn private_compression_99_is_a_raw() { diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index 371b5e4ab..d34fb4c6f 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -31,6 +31,21 @@ pub fn embedded_preview_color_space(bytes: &[u8]) -> Option { } } +/// Whether the camera optimised its embedded JPEG's dynamic range with local tone mapping that the raw data doesn't +/// carry: Sony's Dynamic Range Optimizer (maker note `0xb025`, see `vendor::arw`). Such a JPEG is brighter in its +/// darker regions than the raw developed with the camera's global tone curve. `Some(false)` when the file says it +/// was off, `None` when it doesn't say (other makers, notes without the tag, undocumented values). +pub fn embedded_preview_dynamic_range_optimized(bytes: &[u8]) -> Option { + let tiff = Tiff::parse(bytes).ok()?; + let make = tiff.ifds.first()?.string(t::MAKE)?; + if !make.trim().to_ascii_uppercase().starts_with("SONY") { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + crate::vendor::arw::dynamic_range_optimizer(&mn) +} + /// Whether `b` looks like a displayable (DCT) JPEG: SOI, and the first SOF marker is not lossless. fn is_dct_jpeg(b: &[u8]) -> bool { if b.len() < 4 || b[0] != 0xff || b[1] != 0xd8 { diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs new file mode 100644 index 000000000..aaa0a6dff --- /dev/null +++ b/crates/raw/src/saturation.rs @@ -0,0 +1,270 @@ +//! Sensors that clip below the stated white level. +//! +//! A DNG's `WhiteLevel` is what the writer says the sample range tops out at, not always where the +//! sensor really saturates: a camera app can state 4095 for 12-bit data that piles up at 3567 (85 % of +//! the range above black). Highlight handling ([`crate::highlight`]) treats samples at the white level +//! as clipped; samples stuck 15 % below it look like real, bright, unclipped light, so white balance +//! multiplies each channel's plateau by a different factor and the highlights turn magenta or cyan. +//! +//! [`RawImage::lift_clipped_samples`] finds the real saturation point of each colour from the samples +//! (a pile-up of identical values near the top of the range) and raises the samples at or above it to +//! the stated white level. Nothing else changes: unclipped samples keep their values, and a file whose +//! data really reaches its white level, or has no pile-up, is left alone. + +use crate::{RawData, RawImage}; + +/// A saturation plateau must hold at least this share of its colour's samples (and 100 of them)... +const MIN_SHARE: f64 = 1e-4; +/// ...and be at least this many times as populated as the average value just below it. +const MIN_SPIKE: f64 = 16.0; +/// Plateaus within this share of the range from the white level are the white level (no lift needed). +const NEAR_WHITE: f32 = 0.03; +/// ...and have next to nothing above it (a real clip point is the top of the data; a popular value in the +/// middle of sparse or curve-coded data has plenty of samples above): at most this share of the plateau +/// lies more than `ABOVE_MARGIN` of the range over it (a few counts of noise or gain spill past the plateau). +const MAX_ABOVE: f64 = 0.02; +const ABOVE_MARGIN: f32 = 0.01; +/// How far below the plateau the "average value just below it" reaches. +const NEIGHBOURHOOD: usize = 64; + +impl RawImage { + /// The value each colour's samples saturate at, when that is clearly below the stated white level + /// (see the module docs). Only single-plane integer CFA data is examined. + pub fn saturation_points(&self) -> [Option; 3] { + let none = [None; 3]; + let (RawData::U16(d), Some(cfa)) = (&self.data, &self.cfa) else { return none }; + let white = self.white_at(0); + let black = self.black.mean(); + // a hostile file can state any levels: NaN, negative or inverted ones are left alone + let levels_ok = black.is_finite() && white.is_finite() && black >= 0.0 && white > black && white <= 65535.0; + if self.cpp != 1 || !cfa.valid() || self.width.checked_mul(self.height) != Some(d.len()) || !levels_ok { + return none; + } + let a = self.active_area; + let fits = |start: usize, len: usize, max: usize| start.checked_add(len).is_some_and(|end| end <= max); + if !fits(a.x, a.width, self.width) || !fits(a.y, a.height, self.height) { + return none; + } + let lo = (black + 0.5 * (white - black)).ceil().max(0.0) as usize; + let hi = (white as usize).min(65535); + if lo + NEIGHBOURHOOD >= hi { + return none; + } + let mut hist = vec![[0u32; 3]; 65536]; + let mut total = [0u64; 3]; + for y in 0..a.height { + let row = &d[(a.y + y) * self.width + a.x..][..a.width]; + for (x, &v) in row.iter().enumerate() { + let c = cfa.color_at(a.x + x, a.y + y).min(2) as usize; + total[c] += 1; + hist[v as usize][c] += 1; + } + } + let mut out = none; + for c in 0..3 { + // the most populated value of the upper half of the range + let Some((v, n)) = (lo..hi).map(|v| (v, hist[v][c])).max_by_key(|&(v, n)| (n, v)) else { continue }; + if (n as f64) < (total[c] as f64 * MIN_SHARE).max(100.0) || v as f32 >= white - NEAR_WHITE * (white - black) { + continue; + } + let below: u64 = (v.saturating_sub(NEIGHBOURHOOD)..v).map(|u| hist[u][c] as u64).sum(); + let from = (v + ((ABOVE_MARGIN * (white - black)) as usize).max(1) + 1).min(hist.len()); + let above: u64 = hist[from..].iter().map(|h| h[c] as u64).sum(); + if n as f64 >= MIN_SPIKE * (below as f64 / NEIGHBOURHOOD as f64).max(1.0) && above as f64 <= MAX_ABOVE * n as f64 { + out[c] = Some(v as u16); + } + } + out + } + + /// Raise samples at or above their colour's real saturation point ([`Self::saturation_points`]) to the + /// stated white level, so highlight reconstruction recognises them as clipped. Returns how many + /// samples changed (0: the data is untouched). + pub fn lift_clipped_samples(&mut self) -> usize { + let points = self.saturation_points(); + if points.iter().all(Option::is_none) { + return 0; + } + let (a, w, white) = (self.active_area, self.width, self.white_at(0).round() as u16); + let Some(cfa) = self.cfa.clone() else { return 0 }; + let RawData::U16(d) = &mut self.data else { return 0 }; + let mut changed = 0; + for y in 0..a.height { + let row = &mut d[(a.y + y) * w + a.x..][..a.width]; + for (x, v) in row.iter_mut().enumerate() { + let c = cfa.color_at(a.x + x, a.y + y).min(2) as usize; + if let Some(p) = points[c] + && *v >= p + && *v < white + { + *v = white; + changed += 1; + } + } + } + changed + } +} + +#[cfg(test)] +mod tests { + use crate::*; + + /// A 64 x 64 RGGB mosaic with a gradient, black 528, whose samples above `clip` (when given) are + /// stuck at `clip` like a sensor that saturates early. + fn raw_with(clip: Option, white: f32) -> RawImage { + let data = (0..64 * 64) + .map(|i| { + let v = 528 + ((i % 64) * 60 + (i / 64) * 7) as u16 % 4000; + clip.map_or(v, |c| v.min(c)) + }) + .collect(); + RawImage { + format: RawFormat::Dng, + width: 64, + height: 64, + cpp: 1, + data: RawData::U16(data), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(528.0), + white: vec![white], + active_area: Rect::new(0, 0, 64, 64), + crop: Rect::new(0, 0, 64, 64), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + } + } + + /// The gradient of `raw_with`, wrapped so it never gets near the top of the range. + fn without_top(r: &mut RawImage) { + if let RawData::U16(d) = &mut r.data { + for v in d.iter_mut() { + *v = 528 + (*v - 528) % 3000; + } + } + } + + #[test] + fn hostile_levels_are_left_alone() { + // a negative or NaN black level used to let the search start below NEIGHBOURHOOD and underflow + for black in [-5000.0, f32::NAN] { + let mut r = raw_with(Some(40), 4095.0); + r.black = BlackLevel::uniform(black); + assert_eq!(r.saturation_points(), [None; 3]); + assert_eq!(r.lift_clipped_samples(), 0); + } + // an active area that runs past the image (or overflows when added up) is refused + let mut r = raw_with(Some(3567), 4095.0); + r.active_area = Rect::new(usize::MAX, 0, 2, 64); + assert_eq!(r.saturation_points(), [None; 3]); + } + + #[test] + fn a_pile_up_below_the_white_level_is_found_and_lifted() { + let mut r = raw_with(Some(3567), 4095.0); + assert!(r.saturation_points().iter().all(|p| *p == Some(3567)), "{:?}", r.saturation_points()); + let before = r.data.clone(); + assert!(r.lift_clipped_samples() > 100); + let (RawData::U16(b), RawData::U16(a)) = (&before, &r.data) else { return }; + for (x, y) in b.iter().zip(a) { + // plateau samples go to the white level, everything else is untouched + assert_eq!(*y, if *x >= 3567 { 4095 } else { *x }); + } + assert_eq!(r.lift_clipped_samples(), 0, "idempotent"); + } + + #[test] + fn data_that_reaches_its_white_level_is_left_alone() { + let mut r = raw_with(Some(4095), 4095.0); + let before = r.data.clone(); + assert_eq!(r.saturation_points(), [None; 3]); + assert_eq!(r.lift_clipped_samples(), 0); + assert_eq!(r.data, before); + } + + #[test] + fn data_without_a_pile_up_is_left_alone() { + let mut r = raw_with(None, 4095.0); + without_top(&mut r); + let before = r.data.clone(); + assert_eq!(r.saturation_points(), [None; 3]); + assert_eq!(r.lift_clipped_samples(), 0); + assert_eq!(r.data, before); + } + + /// Sparse or curve-coded data has popular values with plenty of samples above them: not a clip point. + #[test] + fn a_popular_value_with_data_above_it_is_not_a_clip_point() { + let mut r = raw_with(None, 4095.0); + without_top(&mut r); + if let RawData::U16(d) = &mut r.data { + for (i, v) in d.iter_mut().enumerate() { + // a third of the samples sit on one code in the upper half, a few hundred are brighter + *v = if i % 3 == 0 { + 3000 + } else if i % 11 == 0 { + 3000 + (i % 700) as u16 + 1 + } else { + *v + }; + } + } + assert_eq!(r.saturation_points(), [None; 3]); + assert_eq!(r.lift_clipped_samples(), 0); + } + + #[test] + fn a_few_stuck_samples_are_not_a_plateau() { + let mut r = raw_with(None, 4095.0); + without_top(&mut r); + if let RawData::U16(d) = &mut r.data { + for v in d.iter_mut().take(20) { + *v = 3800; + } + } + assert_eq!(r.saturation_points(), [None; 3]); + } + + /// The decoder applies it: a DNG that states 4095 but saturates at 3567 comes back with the plateau at 4095. + #[test] + fn decoding_a_dng_lifts_the_plateau() { + let r = raw_with(Some(3567), 4095.0); + let opts = DngWriteOptions { compression: DngCompression::Uncompressed, ..Default::default() }; + let bytes = write_dng(&r, &opts).unwrap_or_default(); + let back = decode(&bytes).map(|b| b.data); + let (Ok(RawData::U16(d)), RawData::U16(orig)) = (back, &r.data) else { panic!("decode failed") }; + assert!(orig.contains(&3567)); + assert!(d.iter().all(|&v| v != 3567)); + assert_eq!(d.iter().filter(|&&v| v == 4095).count(), orig.iter().filter(|&&v| v == 3567).count()); + } + + /// The reported bug end to end: a white wall that clips at 3567 under a stated 4095, with the camera's + /// white balance (red x2.46, blue x1.59), must come out neutral after highlight reconstruction. + #[test] + fn early_clipping_renders_neutral_after_white_balance() { + let wb = [2.46f32, 1.0, 1.59]; + let mut r = raw_with(None, 4095.0); + let cfa = r.cfa.clone().unwrap_or_else(Cfa::xtrans); + // a wall far brighter than any channel's clip point + let wall = Rgb32f::from_fn(64, 64, |_, _| [4.0, 4.0, 4.0]); + let n = demosaic::mosaic_from_rgb(&wall, &cfa); + r.data = RawData::U16(n.data.iter().map(|&v| (528.0 + v * 3039.0).min(3567.0) as u16).collect()); + let render = |r: &RawImage| { + let mut img = r.develop(Method::Bilinear).unwrap_or_else(|_| Rgb32f::new(1, 1)); + highlight::reconstruct(&mut img, wb, 0.99); + let p = img.get(32, 32); + [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]] + }; + let tint = |p: [f32; 3]| (p[0] / p[1] - 1.0).abs().max((p[2] / p[1] - 1.0).abs()); + let before = render(&r); + assert!(tint(before) > 0.3, "the plateau is not recognised as clipped: {before:?}"); + assert!(r.lift_clipped_samples() > 1000); + let after = render(&r); + assert!(tint(after) < 0.01, "{after:?}"); + } +} diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 7c2990466..0fa501ac1 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -14,11 +14,23 @@ //! a 128-bit little-endian block: 11-bit max, 11-bit min, 4-bit index of the max, 4-bit index of the min and 14 //! seven-bit deltas above the min, scaled by the smallest shift that fits `max − min` into 7 bits. //! -//! White balance: the raw IFD's `0x7313` levels when present (bodies from about 2017 on); otherwise the -//! `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]). Without -//! either, an ARW opened with unit multipliers, i.e. a strong green cast (issue #148). Black level: the raw IFD's -//! `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]; 800 rather than the -//! default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). +//! White balance: `WB_RGGBLevels` (`0x7313`), the gains the camera applied. Bodies from about 2017 on write them in +//! plain form in the raw IFD; every ARW from the DSLR-A200 on has them in the encrypted `SR2SubIFD` (see +//! [`super::sr2`]), and the two agree on every file that has both. Only when neither can be read do we fall back on +//! the `WB_RGBLevels` of the maker note's enciphered `Tag2010` block (see [`DECIPHER`] and [`TAG2010_WB`]), which +//! can differ widely from what the camera applied (issue #535). Without any, an ARW opened with unit multipliers, +//! i.e. a strong green cast (issue #148). +//! +//! Black level: the raw IFD's `0x7310`, else the level stored in the encrypted `SR2SubIFD` (see [`sr2_black`]; 800 +//! rather than the default 512 on 1″-sensor bodies such as the RX100 series), else 512 (14-bit) / 128 (12-bit). +//! Downsized lossless (YCbCr) data sit [`YCBCR_OFFSET`] above that. +//! +//! The Sony DSC-R1 (.SR2, 2005) stores uncompressed 16-bit words big-endian inside a little-endian TIFF +//! ([`word16_order`]). Measured on the CC0 DSC-R1 (raw.pixls.us 3221): StripByteCounts 20780544 = 3984×2608×2; read +//! big-endian the maximum is 16368 and the image is smooth (neighbour roughness 0.012 against 0.249), read +//! little-endian the maximum is 65340 (noise). Black stays the default 512 (the masked columns read 511.4) and white +//! comes from the data. The crop and white balance are NOT addressed here: the file has no maker-note `FullImageSize` +//! and no plain WB tag (they live in its `SR2SubIFD`), so it opens uncropped with unit multipliers. //! //! Also: uncompressed 16-bit ARW, and lossless-compressed ARW (Compression 7, ILCE-7M4 and later): LJ92 tiles whose //! frames hold one 2×2 CFA cell per four-component sample ([`read_quad_tiles`]). Other lossless-JPEG layouts go @@ -42,6 +54,23 @@ const REFERENCE_BLACK_WHITE: u16 = 532; /// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width). const MN_TAG2010: u16 = 0x2010; const MN_FULL_IMAGE_SIZE: u16 = 0xb02b; +/// Maker-note `DynamicRangeOptimizer` (ExifTool Sony tag documentation, int32u): 0 Off, 1 Standard, 2 Advanced +/// Auto, 3 Auto, 8–12 Advanced Lv1–Lv5, 16–23 Lv1–Lv8. Sony writes a second tag of that name, `0xb04f` (0 Off, +/// 1 Standard, 2 Plus), but it adds nothing: on the 241 Sony raws (75 bodies) of 264 checked that carry both, it +/// reads 1 whenever `0xb025` is on, whatever the level, and 0 when off; older bodies (DSLR-A100 to A900, +/// NEX-3/5/C3, SLT-A33/A35/A55) leave it out. `0xb025` was present on all 264 files (98 bodies). +const MN_DYNAMIC_RANGE_OPTIMIZER: u16 = 0xb025; + +/// Whether the camera's Dynamic Range Optimizer (DRO) was on for this shot. DRO is a local tone operator that +/// brightens darker regions of the camera's JPEG (and the preview embedded in the ARW) but leaves the raw data +/// alone; the factory default is Auto. `None` when the maker note doesn't record it or holds an undocumented value. +pub(crate) fn dynamic_range_optimizer(note: &makernote::MakerNote) -> Option { + match note.ifd.u64(MN_DYNAMIC_RANGE_OPTIMIZER)? { + 0 => Some(false), + 1..=3 | 8..=12 | 16..=23 => Some(true), + _ => None, + } +} /// Inverse of Sony's maker-note byte substitution. ExifTool's Sony tag documentation states that the data of /// tags `0x2010`, `0x9050` and `0x94xx` "is encrypted by a simple substitution cipher" (no decoder source was @@ -62,9 +91,12 @@ const DECIPHER: [u8; 256] = { }; /// `Tag2010` layouts (ExifTool "Sony Tag2010a" … "Tag2010i" tables): the models each is documented for and the -/// byte offset of `WB_RGBLevels` (three `u16`, the as-shot white balance as R, G, B gains; Tag2010g and h share -/// the offset). Newer bodies (Tag2010i) also write the levels in plain form in the raw IFD (`0x7313`), which takes -/// precedence. +/// byte offset of `WB_RGBLevels` (three `u16`, R, G, B gains; Tag2010g and h share the offset). Only a fallback +/// for files whose `WB_RGGBLevels` (`0x7313`, plain or in the `SR2SubIFD`) cannot be read. On the 45 CC0 +/// raw.pixls.us ARWs that have both but no plain `0x7313`, the two agree within 5 % on 35 and differ by 8–100 % +/// on the other 10: five of the eight shot with a preset, colour temperature or custom white balance (ILCE-3500 at +/// 5600 K: R −34 %, B +100 %) and five in Auto (SLT-A37: R −32 %, B +53 %). Where the camera JPEG has enough +/// neutral pixels to tell, the gains the camera applied are `WB_RGGBLevels`, not these. const TAG2010_WB: &[(&[&str], usize)] = &[ (&["NEX-5N"], 4476), (&["SLT-A65", "SLT-A77", "NEX-7", "NEX-VG20E"], 4480), @@ -154,9 +186,9 @@ const TAG2010_WB: &[(&[&str], usize)] = &[ ), ]; -/// As-shot white balance from the maker note's enciphered `Tag2010` block (bodies that do not write `0x7313` in -/// the raw IFD: most ARWs before 2017). `model` is the Exif model; Sony appends a regional "V" to some names -/// (SLT-A77V), which the documented lists omit. +/// White balance from the maker note's enciphered `Tag2010` block, the fallback described at [`TAG2010_WB`]. +/// `model` is the Exif model; Sony appends a regional "V" to some names (SLT-A77V), which the documented lists +/// omit. fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<[f32; 3]> { let model = model.trim(); let &(_, offset) = TAG2010_WB.iter().find(|(models, _)| models.iter().any(|m| model == *m || model.strip_suffix('V') == Some(*m)))?; @@ -171,6 +203,18 @@ fn tag2010_wb(model: &str, block: &[u8], order: lightcraft_tiff::ByteOrder) -> O ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r, 1.0, b]) } +/// White-balance gains `[R/G, 1, B/G]` from `WB_RGGBLevels` (R, G, G, B); `None` unless there are four levels +/// with a positive green and plausible ratios. +fn rggb_gains(levels: &[f64]) -> Option<[f32; 3]> { + let &[r, g1, g2, b] = levels else { return None }; + let g = (g1 + g2) / 2.0; + if !g.is_finite() || g <= 0.0 { + return None; + } + let (r, b) = (r / g, b / g); + ((0.2..=8.0).contains(&r) && (0.2..=8.0).contains(&b)).then_some([r as f32, 1.0, b as f32]) +} + /// The inverse tone curve: 11-bit code → 14-bit sensor value. pub(crate) fn code_curve(thresholds: &[u64]) -> Vec { let mut bp = [0usize, 4095, 4095, 4095, 4095, 4095]; @@ -302,6 +346,18 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result> { Ok(out) } +/// Sony's downsized lossless ARWs (M and S sizes: linear YCbCr, already white-balanced) are stored this far above the +/// sensor black level the raw IFD records (`0x7310`, 512 in every file seen); their reference levels say Y black 0. +/// Measured against a full-size coding of the same scene shot seconds apart, on the pixels where both camera JPEGs +/// agree: YCbCr = k · (full-size − black) · WB + b, with b 1015–1052 and green k 0.98–1.02, on 11 of 12 files from +/// four bodies (ILCE-7M4 M, S and APS-C S; ILCE-7RM5 M, S and S35 S; ILCE-7CR M, S and S35 S; ILCE-9M3 M, S and +/// APS-C S; the twelfth's reference was shot 24 s later, 7 % brighter). Their darkest 0.1 % lies at 1047–1127. So +/// black and white both move up by 512: the scale stays the full-size raw's, and an M/S file renders like its +/// full-size twin. Subtracting only 512 left a pedestal of 3 % of white, lifting the shadows (issue #535). +const YCBCR_OFFSET: f32 = 512.0; +/// White of the YCbCr data before [`YCBCR_OFFSET`]: the reference levels' Y white (required by [`read_ycbcr_tiles`]). +const YCBCR_WHITE: f32 = 16383.0; + /// Downsized M/S ARWs are linear YCbCr 4:2:0 / 4:2:2, not CFA, despite retaining dummy CFA tags. /// T.81 supplies the lossless coding; TIFF 6.0 supplies the YCbCr coefficients and reference /// levels. Sony's reference chroma black and white are identical (the neutral offset). @@ -367,57 +423,48 @@ fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Re Ok(out) } -/// `SR2Private` tags (ExifTool "Sony SR2Private" table; the IFD is referenced by IFD0's `DNGPrivateData`). -const SR2_SUBIFD_OFFSET: u16 = 0x7200; -const SR2_SUBIFD_LENGTH: u16 = 0x7201; -const SR2_SUBIFD_KEY: u16 = 0x7221; -/// Black level inside the encrypted `SR2SubIFD` (bodies that do not write `0x7310` in the raw IFD), recovered by -/// black-box known-plaintext analysis of CC0 raw.pixls.us samples; no description of Sony's cipher was used. +/// The black level stored in the encrypted `SR2SubIFD` (`0x7310`; bodies that do not write it in the raw IFD keep +/// it only there), in 14-bit units; `None` unless it is SHORT, count 4, and the four levels are equal and plausible. /// -/// Every Sony ARW we examined (16 files, 15 bodies, 2012–2021) stores the same key (`11 22 33 44`), so its -/// keystream is the same in every file. The DSC-RX100M6 and ILCE-7M3 also write their black levels in plain form -/// (`0x7310`: 800 and 512); XOR-ing their `SR2SubIFD`s against each other matched `pack(512×4) ^ pack(800×4)` at -/// exactly one position, byte 2510, which gave the eight keystream bytes there. Applied to the other files at that -/// position they decode to four equal levels that agree with each sensor's dark-pixel floor: 800 on every 1″-sensor -/// body (RX10, RX100 … RX100M5), 512 on the APS-C/full-frame ones (NEX-5N/6, SLT-A77, ILCE-6000/7/7M2/7RM2), in -/// both `SR2SubIFD` sizes seen (29252 and 56958 bytes). Newer layouts (ILCE-7M4, 35398 bytes) put other data there -/// and are rejected by the plausibility checks; those bodies write `0x7310` anyway. -const SR2_BLACK_AT: usize = 2510; -const SR2_BLACK_KEYSTREAM: [u8; 8] = [0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]; -const SR2_KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; - -/// The per-channel black level from the encrypted `SR2SubIFD` (see [`SR2_BLACK_AT`]), in 14-bit units; `None` -/// unless the file uses the known key and the four decoded levels are equal and plausible. -fn sr2_black(bytes: &[u8], ifd0: &Ifd, order: lightcraft_tiff::ByteOrder) -> Option { - let private = ifd0.bytes(t::DNG_PRIVATE_DATA)?; - let at = order.read_u32(private, 0)? as u64; - let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; - let (sr2, _) = lightcraft_tiff::parse_ifd_at(bytes, at, order, 0, false, &opts).ok()?; - if sr2.bytes(SR2_SUBIFD_KEY)? != SR2_KEY { - return None; +/// Its value sits at a layout-dependent position (1638 … 2786 bytes into the block), which the directory entry +/// gives. Reading a fixed position instead (2510, right for the 29252, 33210 and 56958-byte layouts) read other data +/// on the DSLR-A450/A500/A550 (27152 bytes: 354–365) and the DSLR-A700 (62112 bytes: 975), whose black is 512 by +/// ExifTool and by the data floor (issue #535). +fn sr2_black(sr2: &super::sr2::SubIfd) -> Option { + match sr2.short_bytes(BLACK_LEVEL)? { + (3, plain) if plain.len() == 8 => sr2_black_levels(plain, sr2.order()), + _ => None, } - let (start, len) = (usize::try_from(sr2.u64(SR2_SUBIFD_OFFSET)?).ok()?, usize::try_from(sr2.u64(SR2_SUBIFD_LENGTH)?).ok()?); - if len < SR2_BLACK_AT + 8 { - return None; - } - sr2_black_levels(bytes.get(start.checked_add(SR2_BLACK_AT)?..start.checked_add(SR2_BLACK_AT + 8)?)?, order) } -/// Decipher the eight `SR2SubIFD` bytes at [`SR2_BLACK_AT`] into one black level (the mean of four near-equal, -/// plausible per-channel levels). -fn sr2_black_levels(cipher: &[u8], order: lightcraft_tiff::ByteOrder) -> Option { - let plain: Vec = cipher.iter().zip(SR2_BLACK_KEYSTREAM).map(|(c, k)| c ^ k).collect(); - let levels: Vec = (0..4).filter_map(|i| order.read_u16(&plain, 2 * i)).collect(); +/// One black level from the four deciphered per-channel levels (their mean), when they are near-equal and plausible. +fn sr2_black_levels(plain: &[u8], order: lightcraft_tiff::ByteOrder) -> Option { + let levels: Vec = (0..4).filter_map(|i| order.read_u16(plain, 2 * i)).collect(); let (&lo, &hi) = (levels.iter().min()?, levels.iter().max()?); (levels.len() == 4 && (64..=4096).contains(&lo) && hi - lo <= 64).then(|| levels.iter().map(|&v| f32::from(v)).sum::() / 4.0) } +/// Byte order of 16-bit-word samples: the file's own order unless the words only fit the sample width in the other +/// (see the DSC-R1 note at the top). Sampled across the strip; the order is swapped only when the file order leaves +/// more than 1 % of the sampled words at or above `1 << max(bits, 14)` while the other order leaves none. +fn word16_order(strip: &[u8], bits: u32, file: lightcraft_tiff::ByteOrder) -> lightcraft_tiff::ByteOrder { + use lightcraft_tiff::ByteOrder::{Big, Little}; + let other = if file == Little { Big } else { Little }; + let limit = 1u32 << bits.clamp(14, 16); + let (mut n, mut over_file, mut over_other) = (0usize, 0usize, 0usize); + let words = strip.len() / 2; + let step = (words / 65536).max(1); + for &b in strip.as_chunks::<2>().0.iter().step_by(step) { + n += 1; + over_file += usize::from(u32::from(file.u16(b)) >= limit); + over_other += usize::from(u32::from(other.u16(b)) >= limit); + } + if n > 0 && over_other == 0 && over_file * 100 > n { other } else { file } +} + /// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style -/// default crop when the raw IFD has one, else the maker note's `FullImageSize` (the camera JPEG's size) anchored -/// at the top-left. Older bodies store a few columns of padding at the right edge of the raw frame (constant -/// values, 8–32 columns on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so -/// the anchored crop removes them while keeping the CFA phase. -fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect { +/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). +fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize) -> Rect { let full = Rect::new(0, 0, w, h); if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref()) && *cw > 0 @@ -426,14 +473,37 @@ fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h); } match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() { - // only a plausible trim: never more than 64 pixels per side, never an enlargement - Some([fh, fw]) if *fw as usize <= w && *fh as usize <= h && *fw as usize + 64 >= w && *fh as usize + 64 >= h => { - Rect::new(0, 0, *fw as usize, *fh as usize) - } + Some([fh, fw]) => recorded_size_crop((*fw, *fh), exif_size, w, h).unwrap_or(full), _ => full, } } +/// The crop for the maker note's `FullImageSize` `full` (width, height: the camera JPEG's size) and the Exif image +/// size `exif` (`PixelXDimension` × `PixelYDimension`) in a `w × h` frame; `None` when they don't describe a crop of +/// it. Older bodies store a few columns of padding at the right edge of the raw frame (constant values, 8–32 columns +/// on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so the crop is anchored at +/// the left, which removes them while keeping the CFA phase. In the camera's 3:2 mode it is also anchored at the +/// top. An in-camera aspect ratio narrower than the frame (16:9) is centred vertically, as measured by registering +/// each camera JPEG on its raw (the ILCE-7SM2's 4240 × 2384 at y 228–232 of 2848; the DSLR-A580's 4912 × 2760 at +/// y 258–262 of 3280; issue #535): its height comes from `FullImageSize` (ILCE-7SM2) or, when that still says +/// 3:2, from an Exif image size of the same width (DSLR-A580). +fn recorded_size_crop(full: (u64, u64), exif: Option<(u64, u64)>, w: usize, h: usize) -> Option { + let (fw, fh) = (usize::try_from(full.0).ok()?, usize::try_from(full.1).ok()?); + // only a plausible trim of the width: never more than 64 pixels, never an enlargement + if fw == 0 || fh == 0 || fw > w || fw.saturating_add(64) < w || fh > h { + return None; + } + let height = match exif.and_then(|(ew, eh)| Some((usize::try_from(ew).ok()?, usize::try_from(eh).ok()?))) { + Some((ew, eh)) if ew == fw && eh > 0 && eh < fh => eh, + _ => fh, + }; + if height.saturating_add(64) >= h { + return Some(Rect::new(0, 0, fw, height)); + } + // an in-camera aspect ratio: at least half the frame's height, centred (an even offset keeps the CFA phase) + (height * 2 >= h).then(|| Rect::new(0, ((h - height) / 2) & !1, fw, height)) +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -486,8 +556,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } }, 1 => { - let packing = if strip_len >= (w * h * 2) as u64 { Packing::Word16 } else { Packing::Msb }; - (read_image_in(mode, bytes, &info, tiff.order, packing)?, bits) + let word16 = strip_len >= (w * h * 2) as u64; + let packing = if word16 { Packing::Word16 } else { Packing::Msb }; + let order = match chunks.first().and_then(|c| chunk_bytes(bytes, c)) { + Some(strip) if word16 => word16_order(strip, bits, tiff.order), + _ => tiff.order, + }; + (read_image_in(mode, bytes, &info, order, packing)?, bits) } _ => (read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?, bits), }; @@ -506,15 +581,19 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { _ => Cfa::bayer_static("RGGB"), }; let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 }; + // the decrypted SR2SubIFD, read once and only when a plain black level or white balance is missing + let sr2_cell = std::cell::OnceCell::new(); + let sr2 = || sr2_cell.get_or_init(|| super::sr2::SubIfd::read(bytes, ifd0, tiff.order)).as_ref(); let black = match raw.f64s(BLACK_LEVEL).as_deref() { - Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::() / v.len() as f64) as f32), + Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::() / v.len() as f64) as f32 + YCBCR_OFFSET), Some([a, b, c, d]) => { BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() } } - _ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)), + _ if linear_rgb => BlackLevel::uniform(default_black + YCBCR_OFFSET), + _ => BlackLevel::uniform((scale_bits >= 14).then(|| sr2().and_then(sr2_black)).flatten().unwrap_or(default_black)), }; let white = if linear_rgb { - 16383.0 + YCBCR_WHITE + YCBCR_OFFSET } else { raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits)) }; @@ -525,15 +604,15 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default())); let wb = raw .f64s(WB_RGGB) - .filter(|v| v.len() == 4 && v[1] > 0.0 && v[0] > 0.0 && v[3] > 0.0) - .map(|v| { - let g = (v[1] + v[2]) / 2.0; - [(v[0] / g) as f32, 1.0, (v[3] / g) as f32] - }) + .and_then(|v| rggb_gains(&v)) + .or_else(|| rggb_gains(&sr2()?.shorts(WB_RGGB)?)) .or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order))); let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) { (Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h), - _ => default_crop(raw, mn.as_ref(), w, h), + _ => { + let exif_size = tiff.exif().and_then(|e| Some((e.u64(t::PIXEL_X_DIMENSION)?, e.u64(t::PIXEL_Y_DIMENSION)?))); + default_crop(raw, mn.as_ref(), exif_size, w, h) + } }; let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); @@ -591,6 +670,9 @@ mod tests { assert!(full.cfa.is_none()); assert_eq!(full.data.len(), 48); assert_eq!(full.wb_multipliers, Some([1.0; 3])); + // the YCbCr data sit 512 above the recorded sensor black level; white moves with it + assert_eq!((full.black.mean(), full.white_at(0)), (1024.0, 16895.0)); + assert_eq!((header.black.mean(), header.white_at(0)), (1024.0, 16895.0)); let RawData::U16(ref pixels) = full.data else { panic!("integer ARW"); }; @@ -599,6 +681,26 @@ mod tests { assert_eq!((developed.width, developed.height), (4, 4)); } + #[test] + fn recorded_size_crops_in_camera_aspect_ratios() { + // 3:2: anchored at the top-left, as before (ILCE-7S, DSLR-A700 whose Exif size is the whole frame) + assert_eq!(recorded_size_crop((4240, 2832), Some((4240, 2832)), 4288, 2848), Some(Rect::new(0, 0, 4240, 2832))); + assert_eq!(recorded_size_crop((4272, 2848), Some((4288, 2856)), 4288, 2856), Some(Rect::new(0, 0, 4272, 2848))); + assert_eq!(recorded_size_crop((4240, 2832), None, 4288, 2848), Some(Rect::new(0, 0, 4240, 2832))); + // 16:9 from FullImageSize (ILCE-7SM2) and from the Exif size when FullImageSize says 3:2 (DSLR-A580): centred + assert_eq!(recorded_size_crop((4240, 2384), Some((4240, 2384)), 4288, 2848), Some(Rect::new(0, 232, 4240, 2384))); + assert_eq!(recorded_size_crop((4912, 3264), Some((4912, 2760)), 4928, 3280), Some(Rect::new(0, 260, 4912, 2760))); + // offsets stay even (CFA phase) + assert_eq!(recorded_size_crop((4240, 2386), None, 4288, 2848).map(|r| r.y), Some(230)); + // not a crop of this frame: wider than it, far narrower, under half its height, empty, hostile values + assert_eq!(recorded_size_crop((4300, 2832), None, 4288, 2848), None); + assert_eq!(recorded_size_crop((4000, 2832), None, 4288, 2848), None); + assert_eq!(recorded_size_crop((4240, 1000), None, 4288, 2848), None); + assert_eq!(recorded_size_crop((0, 2832), None, 4288, 2848), None); + assert_eq!(recorded_size_crop((u64::MAX, u64::MAX), Some((u64::MAX, 1)), 4288, 2848), None); + assert_eq!(recorded_size_crop((4240, 2832), Some((4240, 0)), 4288, 2848), Some(Rect::new(0, 0, 4240, 2832))); + } + /// A 32767-compressed ARW whose single strip is `strip` bytes for a 32 x 4 image. fn arw_with_strip(strip: usize) -> Vec { use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; @@ -732,19 +834,320 @@ mod tests { assert!(tag2010_wb("DSC-RX100M3", &encipher(&odd), Little).is_none()); } + /// A Sony-style file: IFD0 (`make`) → Exif → maker note `SONY DSC \0\0\0` + an IFD of `entries` (tag, type, + /// count, inline value bytes), the layout of the camera's own notes; `count` overrides the entry count. + fn sony_file(order: lightcraft_tiff::ByteOrder, make: &str, entries: &[(u16, u16, u32, [u8; 4])], count: Option) -> Vec { + use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + let (u16b, u32b) = match order { + ByteOrder::Little => (u16::to_le_bytes as fn(u16) -> [u8; 2], u32::to_le_bytes as fn(u32) -> [u8; 4]), + ByteOrder::Big => (u16::to_be_bytes as fn(u16) -> [u8; 2], u32::to_be_bytes as fn(u32) -> [u8; 4]), + }; + let mut note = b"SONY DSC \0\0\0".to_vec(); + note.extend(u16b(count.unwrap_or(entries.len() as u16))); + for &(tag, kind, n, value) in entries { + note.extend(u16b(tag)); + note.extend(u16b(kind)); + note.extend(u32b(n)); + note.extend(value); + } + note.extend(u32b(0)); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + } + + #[test] + fn dynamic_range_optimizer_is_read_from_the_sony_note() { + use lightcraft_tiff::ByteOrder::{Big, Little}; + let long = |order, v: u32| match order { + Little => v.to_le_bytes(), + Big => v.to_be_bytes(), + }; + let short = |order, v: u16| match order { + Little => [v.to_le_bytes()[0], v.to_le_bytes()[1], 0, 0], + Big => [v.to_be_bytes()[0], v.to_be_bytes()[1], 0, 0], + }; + let dro = |bytes: &[u8]| crate::embedded_preview_dynamic_range_optimized(bytes); + for order in [Little, Big] { + // every documented value: Off; Standard, Advanced Auto, Auto (the factory default); Advanced Lv1–5; Lv1–8 + let documented: Vec = [0, 1, 2, 3].into_iter().chain(8..=12).chain(16..=23).collect(); + for &value in &documented { + let file = sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, long(order, value))], None); + assert_eq!(dro(&file), Some(value != 0), "{order:?} {value}"); + } + // undocumented values say nothing + for value in (0..=64).filter(|v| !documented.contains(v)).chain([255, 65535, u32::MAX]) { + let file = sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, long(order, value))], None); + assert_eq!(dro(&file), None, "{order:?} {value}"); + } + // stored as a SHORT: same reading + assert_eq!(dro(&sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 3, 1, short(order, 18))], None)), Some(true)); + // the coarse 0xb04f alone is not used, nor is an ASCII or empty value + assert_eq!(dro(&sony_file(order, "SONY", &[(0xb04f, 3, 1, short(order, 1))], None)), None); + assert_eq!(dro(&sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 2, 2, *b"3\0\0\0")], None)), None); + assert_eq!(dro(&sony_file(order, "SONY", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 0, [0; 4])], None)), None); + // other makers' notes are not read as Sony's + assert_eq!(dro(&sony_file(order, "NIKON CORPORATION", &[(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, long(order, 3))], None)), None); + } + } + + #[test] + fn hostile_sony_notes_are_read_safely() { + use lightcraft_tiff::ByteOrder::Little; + let entry = [(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 1, 3u32.to_le_bytes())]; + let good = sony_file(Little, "SONY", &entry, None); + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&good), Some(true)); + // an entry count far beyond the note: at most the entries actually there are read + assert_ne!(crate::embedded_preview_dynamic_range_optimized(&sony_file(Little, "SONY", &entry, Some(u16::MAX))), Some(false)); + // an entry pointing outside the file + let outside = [(MN_DYNAMIC_RANGE_OPTIMIZER, 4, 2, 0xffff_fff0u32.to_le_bytes())]; + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&sony_file(Little, "SONY", &outside, None)), None); + // every truncation of a good file, and no file at all + for len in 0..good.len() { + let _ = crate::embedded_preview_dynamic_range_optimized(&good[..len]); + } + assert_eq!(crate::embedded_preview_dynamic_range_optimized(b"not a TIFF"), None); + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&[]), None); + // a Sony file without a maker note + let mut ifd0 = lightcraft_tiff::IfdBuilder::new(); + ifd0.set(t::MAKE, lightcraft_tiff::Value::Ascii("SONY".into())); + assert_eq!(crate::embedded_preview_dynamic_range_optimized(&lightcraft_tiff::TiffWriter::new(Little, false).write(&[ifd0]).unwrap()), None); + } + + #[test] + fn rggb_gains_need_four_plausible_levels() { + assert_eq!(rggb_gains(&[2048.0, 1024.0, 1024.0, 1536.0]), Some([2.0, 1.0, 1.5])); + assert_eq!(rggb_gains(&[2048.0, 1000.0, 1048.0, 1536.0]), Some([2.0, 1.0, 1.5])); + for bad in [ + &[2048.0, 1024.0, 1024.0][..], + &[2048.0, 1024.0, 1024.0, 1536.0, 1.0], + &[2048.0, 1024.0, -1024.0, 1536.0], + &[2048.0, 0.0, 0.0, 1536.0], + &[2048.0, f64::NAN, 1024.0, 1536.0], + &[-2048.0, 1024.0, 1024.0, 1536.0], + &[9000.0, 1024.0, 1024.0, 1536.0], + &[2048.0, 1024.0, 1024.0, f64::INFINITY], + ] { + assert_eq!(rggb_gains(bad), None, "{bad:?}"); + } + } + + /// A 32 × 4 ARW2 file whose white balance can come from the raw IFD's `0x7313`, an encrypted `SR2SubIFD` + /// (reached through `DNGPrivateData` and the `SR2Private` IFD, as Sony stores it, with `key`; black level 800) + /// and a maker note whose `Tag2010` block (RX100 III layout) holds `WB_RGBLevels`. + fn arw_with_wb(plain: Option<[u16; 4]>, sr2: Option<[u16; 4]>, tag2010: Option<[u16; 3]>, key: [u8; 4]) -> Vec { + use lightcraft_tiff::{ByteOrder::Little, IfdBuilder, ImageData, TiffWriter, Value}; + let mut tag2010_block = vec![0u8; 700]; + for (i, v) in tag2010.unwrap_or([0; 3]).iter().enumerate() { + tag2010_block[612 + 2 * i..614 + 2 * i].copy_from_slice(&v.to_le_bytes()); + } + let tag2010_block = encipher(&tag2010_block); + // the maker note: "SONY DSC " header, then an IFD whose offsets are file offsets + let note = |at: usize| -> Vec { + let mut n = b"SONY DSC \0\0\0".to_vec(); + n.extend([1, 0]); + n.extend(MN_TAG2010.to_le_bytes()); + n.extend(7u16.to_le_bytes()); + n.extend((tag2010_block.len() as u32).to_le_bytes()); + n.extend(((at + n.len() + 8) as u32).to_le_bytes()); + n.extend([0; 4]); + n.extend(&tag2010_block); + n + }; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("DSC-RX100M3".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(TONE_CURVE, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + if let Some(levels) = plain { + raw.set(WB_RGGB, Value::Short(levels.to_vec())); + } + if tag2010.is_some() { + raw.set_child(t::EXIF_IFD, IfdBuilder::new().with(t::MAKER_NOTE, Value::Undefined(note(0)))); + } + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0u8; 32 * 4]] }); + let mut file = TiffWriter::default().write(&[raw]).unwrap(); + let parsed = Tiff::parse(&file).unwrap(); + if let Some(e) = parsed.exif().and_then(|e| e.get(t::MAKER_NOTE)) { + let at = e.offset as usize; + file[at..at + e.count()].copy_from_slice(¬e(at)); + } + if let Some(levels) = sr2 { + // SR2Private IFD (three entries), then the encrypted SR2SubIFD + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let block = super::super::sr2::tests::block(start, &[(BLACK_LEVEL, 3, &[800; 4]), (WB_RGGB, 8, &levels)], Little); + file.extend([3, 0]); + for (tag, kind, value) in + [(0x7200u16, 4u16, (start as u32).to_le_bytes()), (0x7201, 4, (block.len() as u32).to_le_bytes()), (0x7221, 7, key)] + { + file.extend(tag.to_le_bytes()); + file.extend(kind.to_le_bytes()); + file.extend(if kind == 7 { 4u32 } else { 1 }.to_le_bytes()); + file.extend(value); + } + file.extend([0; 4]); + file.extend(block); + let at = parsed.ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + file[at..at + 4].copy_from_slice(&(private as u32).to_le_bytes()); + } + file + } + + #[test] + fn white_balance_prefers_the_applied_levels() { + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + let (plain, sr2, tag2010) = ([2048, 1024, 1024, 1536], [2932, 1024, 1024, 1576], [484, 256, 788]); + let wb = |file: Vec| { + let header = decode(&file, Mode::Header).unwrap(); + let full = decode(&file, Mode::Full).unwrap(); + assert_eq!(header.wb_multipliers, full.wb_multipliers); + assert_eq!(header.black, full.black); + full.wb_multipliers + }; + // the same decrypted directory gives the black level, with the known key only + let black = |file: Vec| decode(&file, Mode::Full).unwrap().black.mean(); + assert_eq!(black(arw_with_wb(None, Some(sr2), None, KEY)), 800.0); + assert_eq!(black(arw_with_wb(None, Some(sr2), None, [1, 2, 3, 4])), 512.0); + assert_eq!(black(arw_with_wb(None, None, None, KEY)), 512.0); + let sr2_gains = Some([2932.0 / 1024.0, 1.0, 1576.0 / 1024.0]); + // the encrypted SR2SubIFD's WB_RGGBLevels over Tag2010 (issue #535: ILCE-3500 at 5600 K) and when alone + assert_eq!(wb(arw_with_wb(None, Some(sr2), Some(tag2010), KEY)), sr2_gains); + assert_eq!(wb(arw_with_wb(None, Some(sr2), None, KEY)), sr2_gains); + // the plain levels over both + assert_eq!(wb(arw_with_wb(Some(plain), Some(sr2), Some(tag2010), KEY)), Some([2.0, 1.0, 1.5])); + // Tag2010 when the SR2SubIFD has another key, implausible levels or none at all + let fallback = Some([484.0 / 256.0, 1.0, 788.0 / 256.0]); + assert_eq!(wb(arw_with_wb(None, Some(sr2), Some(tag2010), [1, 2, 3, 4])), fallback); + assert_eq!(wb(arw_with_wb(None, Some([9000, 1024, 1024, 1576]), Some(tag2010), KEY)), fallback); + assert_eq!(wb(arw_with_wb(None, None, Some(tag2010), KEY)), fallback); + assert_eq!(wb(arw_with_wb(None, None, None, KEY)), None); + } + + #[test] + fn broken_sr2_private_data_is_ignored() { + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + let file = arw_with_wb(None, Some([2932, 1024, 1024, 1576]), None, KEY); + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = u32::from_le_bytes(file[at..at + 4].try_into().unwrap()) as usize; + let patched = |pos: usize, bytes: &[u8]| { + let mut f = file.clone(); + f[pos..pos + bytes.len()].copy_from_slice(bytes); + decode(&f, Mode::Full).map(|img| img.wb_multipliers) + }; + // DNGPrivateData pointing nowhere or into the image; SR2SubIFD offset or length past the end, or enormous + for (pos, bytes) in [ + (at, u32::MAX.to_le_bytes()), + (at, 8u32.to_le_bytes()), + (private + 2 + 8, u32::MAX.to_le_bytes()), + (private + 2 + 12 + 8, u32::MAX.to_le_bytes()), + (private + 2 + 12 + 8, (file.len() as u32).to_le_bytes()), + (private + 2 + 12 + 8, (1u32 << 21).to_le_bytes()), + ] { + assert_eq!(patched(pos, &bytes).unwrap(), None, "{pos} {bytes:?}"); + } + // the file cut anywhere inside the SR2 data still decodes (the image comes first), without white balance + for len in [private, private + 20, file.len() - 30, file.len() - 1] { + let img = decode(&file[..len], Mode::Full).unwrap(); + assert_eq!(img.wb_multipliers, None, "{len}"); + } + } + #[test] fn sr2_black_level_decodes_and_rejects_garbage() { use lightcraft_tiff::ByteOrder::Little; - let cipher = - |levels: [u16; 4]| -> Vec { levels.iter().flat_map(|v| v.to_le_bytes()).zip(SR2_BLACK_KEYSTREAM).map(|(p, k)| p ^ k).collect() }; - assert_eq!(sr2_black_levels(&cipher([800; 4]), Little), Some(800.0)); - assert_eq!(sr2_black_levels(&cipher([512, 512, 514, 512]), Little), Some(512.5)); - assert_eq!(sr2_black_levels(&cipher([65320, 64928, 65260, 488]), Little), None); // ILCE-7M4's layout - assert_eq!(sr2_black_levels(&cipher([0; 4]), Little), None); - assert_eq!(sr2_black_levels(&cipher([800; 4])[..5], Little), None); + let plain = |levels: [u16; 4]| -> Vec { levels.iter().flat_map(|v| v.to_le_bytes()).collect() }; + assert_eq!(sr2_black_levels(&plain([800; 4]), Little), Some(800.0)); + assert_eq!(sr2_black_levels(&plain([512, 512, 514, 512]), Little), Some(512.5)); + assert_eq!(sr2_black_levels(&plain([65320, 64928, 65260, 488]), Little), None); + assert_eq!(sr2_black_levels(&plain([0; 4]), Little), None); + assert_eq!(sr2_black_levels(&plain([800; 4])[..5], Little), None); assert_eq!(sr2_black_levels(&[], Little), None); } + /// An encrypted `SR2SubIFD` of `len` bytes at file offset `start` whose first entry is a black level stored at + /// `value_pos` (relative), holding `levels`; `other` puts four more levels at position 2510 (where the old + /// fixed read looked). + fn sr2_block(start: usize, len: usize, tag: u16, value_pos: usize, levels: [u16; 4], other: Option<[u16; 4]>) -> Vec { + let mut plain = vec![0u8; len]; + plain[0..2].copy_from_slice(&180u16.to_le_bytes()); + plain[2..4].copy_from_slice(&tag.to_le_bytes()); + plain[4..6].copy_from_slice(&3u16.to_le_bytes()); + plain[6..10].copy_from_slice(&4u32.to_le_bytes()); + plain[10..14].copy_from_slice(&((start + value_pos) as u32).to_le_bytes()); + for (i, v) in levels.iter().enumerate() { + plain[value_pos + 2 * i..value_pos + 2 * i + 2].copy_from_slice(&v.to_le_bytes()); + } + if let Some(o) = other { + for (i, v) in o.iter().enumerate() { + plain[2510 + 2 * i..2510 + 2 * i + 2].copy_from_slice(&v.to_le_bytes()); + } + } + super::super::sr2::tests::encrypt(&mut plain); + plain + } + + /// [`sr2_black`] on the encrypted `SR2SubIFD` bytes `block`, which start at file offset `start`. + fn sr2_black_in(block: &[u8], start: usize, order: lightcraft_tiff::ByteOrder) -> Option { + sr2_black(&super::super::sr2::SubIfd::decrypt(block, start, order)) + } + + #[test] + fn sr2_black_level_follows_the_first_entry_to_its_layout() { + use lightcraft_tiff::ByteOrder::Little; + let start = 37584; + // the value position of every layout seen, e.g. the DSLR-A700's (62112 bytes, value at 1638) and the + // DSLR-A450/A500/A550's (27152 bytes, value at 2166); the first four are those of bodies without a plain 0x7310 + for pos in [1638, 2166, 2418, 2510, 2030, 2102, 2186, 2558, 2786] { + for level in [512, 800] { + let block = sr2_block(start, 27152, BLACK_LEVEL, pos, [level; 4], None); + assert_eq!(sr2_black_in(&block, start, Little), Some(f32::from(level)), "value at {pos}"); + } + } + // the A700's layout holds four other equal values at 2510 (975 each); the old fixed read took those + let a700 = sr2_block(start, 62112, BLACK_LEVEL, 1638, [512; 4], Some([975; 4])); + assert_eq!(sr2_black_in(&a700, start, Little), Some(512.0)); + // not a black-level entry, another type, a value outside the block, before it + assert_eq!(sr2_black_in(&sr2_block(start, 27152, 0x7311, 2166, [512; 4], None), start, Little), None); + let mut long = sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None); + long[4] ^= 3 ^ 4; // SHORT -> LONG (type field of the first entry) + assert_eq!(sr2_black_in(&long, start, Little), None); + assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None)[..2170], start, Little), None); + let block = sr2_block(start, 27152, BLACK_LEVEL, 2166, [512; 4], None); + assert_eq!(sr2_black_in(&block, start + 4000, Little), None, "value offset before the block"); + assert_eq!(sr2_black_in(&block[..10], start, Little), None, "truncated directory"); + assert_eq!(sr2_black_in(&[], start, Little), None); + // implausible levels at a known position + assert_eq!(sr2_black_in(&sr2_block(start, 27152, BLACK_LEVEL, 2166, [4; 4], None), start, Little), None); + } + + #[test] + fn word16_byte_order_follows_the_sample_range() { + use lightcraft_tiff::ByteOrder::{Big, Little}; + let be: Vec = (0..4096u16).flat_map(|i| (512 + i * 3).to_be_bytes()).collect(); // 512..12800 big-endian words + assert_eq!(word16_order(&be, 14, Little), Big); + let le: Vec = (0..4096u16).flat_map(|i| (512 + i * 3).to_le_bytes()).collect(); + assert_eq!(word16_order(&le, 14, Little), Little); + // values whose bytes are all below 0x40 fit both ways: keep the file order + let amb: Vec = (0..4096usize).flat_map(|i| [(i % 60) as u8, (i % 50) as u8]).collect(); + assert_eq!(word16_order(&amb, 14, Little), Little); + assert_eq!(word16_order(&[], 14, Big), Big); + // any `bits` value (even a corrupt one) clamps to 14..=16 instead of overflowing the shift + for bits in [0, 1, 12, 14, 16, 17, 31, 32, 33, u32::MAX] { + let _ = word16_order(&be, bits, Little); + } + } + #[test] fn quad_tiles_place_cfa_cells() { // 14×12 mosaic in 8×8 tiles (the right and bottom tiles overhang), each tile a 4×4 frame of 2×2 cells, diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 4c7522dab..7f52888f4 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -13,7 +13,9 @@ //! read per file (no model table); files without the tag fall back to RGGB (issue #85). The parity of the //! `SensorInfo` borders does *not* predict it (RGGB files come with both even and odd top borders). //! - `SensorInfo` (maker note `0x00e0`): sensor width/height and the left/top/right/bottom borders of the image -//! area; the masked columns left of it give the black level. +//! area. Light already reaches some columns left of that border (EOS 6D: masked columns 0–70, border 84; 10–36 +//! such columns on every corpus body), so the masked columns that give the black level are measured from the +//! data ([`masked_columns`]) rather than taken as the whole border. //! - `ColorBalance` (maker note `0x4001`): as-shot `RGGB` levels at a model-dependent offset; we probe the known //! offsets and accept the first plausible quadruple. The array is 16-bit words; some models store it as UNDEFINED //! bytes (ColorData versions -3 and -4), which are paired up in the maker note's byte order first. @@ -26,6 +28,7 @@ use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, Ra use lightcraft_geom::Orientation; use lightcraft_tiff::image::chunk_bytes; use lightcraft_tiff::{ByteOrder, Ifd, Tiff, Value, makernote, tags as t}; +use std::ops::Range; const CR2_SLICE: u16 = 0xc640; const SRAW_TYPE: u16 = 0xc6c5; @@ -79,6 +82,38 @@ fn wb_from_color_balance(v: &[u64]) -> Option<[f32; 3]> { None } +/// The optically black columns left of the image area: from column 2 up to two columns before the first column +/// whose mean (over the image rows) rises above the leftmost columns' level by more than 1/256 of the remaining +/// range (at least 16), else up to two columns before the image area. Empty when the border is too narrow. +fn masked_columns(data: &[u16], width: usize, active: Rect, white: f32) -> Range { + let limit = active.x.saturating_sub(2).min(width); + if limit < 4 || active.height == 0 { + return 0..0; + } + let step = (active.height / 256).max(1); + let column_mean = |x: usize| { + let (mut sum, mut n) = (0.0, 0usize); + for y in (active.y..active.y.saturating_add(active.height)).step_by(step) { + if let Some(&v) = y.checked_mul(width).and_then(|i| data.get(i.checked_add(x)?)) { + sum += f64::from(v); + n += 1; + } + } + (n > 0).then(|| sum / n as f64) + }; + let means: Vec = (2..limit).map_while(column_mean).collect(); + let mut first: Vec = means.iter().take(8).copied().collect(); + first.sort_by(f64::total_cmp); + let Some(&level) = first.get(first.len() / 2) else { return 0..0 }; + let tolerance = ((f64::from(white) - level) / 256.0).max(16.0); + let end = match means.iter().position(|&m| m > level + tolerance) { + // `means[k]` is column `2 + k`: stop two columns before it + Some(k) => k, + None => 2 + means.len(), + }; + 2..end.max(2) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; @@ -154,12 +189,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { } let wb = mn.as_ref().and_then(|m| m.ifd.value(COLOR_BALANCE).map(|v| words(v, m.order))).and_then(|v| wb_from_color_balance(&v)); let cfa = cfa_from_tag(raw.u64(CR2_CFA_PATTERN)).unwrap_or_else(|| Cfa::bayer_static("RGGB")); - let black = if active.x >= 8 { - black_from_columns(&data, width, 2..active.x - 2, active.y..active.y + active.height, active) - } else { - BlackLevel::uniform(0.0) - }; let white = white_from_data(&data, prec as u32); + let black = match masked_columns(&data, width, active, white) { + cols if cols.len() >= 2 => black_from_columns(&data, width, cols, active.y..active.y + active.height, active), + _ => BlackLevel::uniform(0.0), + }; let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(active.width as u32); metadata.height = Some(active.height as u32); @@ -299,6 +333,25 @@ pub(crate) mod tests { assert_eq!(words(&Value::Undefined(vec![1, 0, 2]), ByteOrder::Little), vec![1]); } + /// Light reaches columns left of the `SensorInfo` border (as on the EOS 6D): the black level comes from the + /// masked columns only, not from the image columns between them and the border. + #[test] + fn black_from_masked_columns_only() { + let (w, h, border, light) = (40, 12, 20, 14); + let data: Vec = (0..w * h).map(|i| if i % w < light { 2048 + (i % 2) as u16 } else { 6000 }).collect(); + let active = Rect::new(border, 2, w - border, h - 2); + let cols = masked_columns(&data, w, active, 15000.0); + assert_eq!(cols, 2..light - 2); + let black = black_from_columns(&data, w, cols, 2..h, active); + assert!(black.values.iter().all(|v| (2048.0..=2049.0).contains(v)), "{:?}", black.values); + // the whole border masked: every column but the two next to the image + let dark: Vec = (0..w * h).map(|i| if i % w < border { 2048 } else { 6000 }).collect(); + assert_eq!(masked_columns(&dark, w, active, 15000.0), 2..border - 2); + // too narrow a border, or no samples (headers-only decode): none + assert!(masked_columns(&dark, w, Rect::new(5, 0, 30, h), 15000.0).is_empty()); + assert!(masked_columns(&[], w, active, 15000.0).is_empty()); + } + /// A maker note holding only `ColorData` (`0x4001`) as `ty` (3 = SHORT, 7 = UNDEFINED) with `n` values, a plain /// IFD whose value offset is relative to the TIFF header (patched once the note's position is known). fn note_with_color_data(ty: u16, data: &[u8]) -> Vec { diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index e9f887c85..e0019c484 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -12,6 +12,7 @@ pub mod pef; pub mod raf; mod rafc; pub mod rw2; +mod sr2; pub mod srw; use crate::{BlackLevel, Cfa, Rect}; diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 5ca66af47..c7b99e937 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -3,8 +3,10 @@ //! Sources: TIFF 6.0 (the raw image is a standard CFA SubIFD), Laurent Clévy's NEF structure notes (prose: IFD //! layout, SubIFDs, maker note header) and the ExifTool Nikon tag-name documentation (`0x000c` WB_RBLevels, //! `0x003d` BlackLevel, `0x0096` NEFLinearizationTable). The Huffman-compressed data (compression 34713) is decoded -//! by [`super::nefc`], which documents its clean-room sources; files it can't decode yet ("lossy after split") -//! are reported as [`RawError::Unsupported`] and their embedded previews still work. +//! by [`super::nefc`], which documents its clean-room sources; "lossy after split" files are decoded when their strip follows the +//! rule documented there; files it can't decode are reported as [`RawError::Unsupported`] and their embedded previews still work. So are High Efficiency NEFs +//! (HE / HE★, Z 8, Z 9, Z 6III, Z f): they keep compression 34713 but carry a wavelet codestream that starts with +//! the JPEG XS markers SOC + CAP (ISO/IEC 21122-1, `FF10 FF50`) and have no `0x0096` table (issue #193). use super::{nefc, white_from_data}; use crate::tiffraw::{Packing, read_image}; @@ -18,6 +20,11 @@ const WB_RB_LEVELS: u16 = 0x000c; const BLACK_LEVEL: u16 = 0x003d; const LINEARIZATION_TABLE: u16 = 0x0096; const CROP_AREA: u16 = 0x0045; +/// JPEG XS start of codestream (SOC, `FF10`) followed by its mandatory capabilities marker (CAP, `FF50`). +const JPEG_XS_START: [u8; 4] = [0xff, 0x10, 0xff, 0x50]; +/// Why a High Efficiency NEF shows its embedded preview: the UI shows the part before " (". +pub(crate) const HIGH_EFFICIENCY: &str = + "Nikon High Efficiency NEF (HE / HE★, a JPEG XS-based codec with no public specification; shoot RAW Lossless compressed to edit the raw data)"; /// Maker note `0x0014`, the "NRW" data block of Coolpix raws: `"NRW "` + a 4-character version, then fields. In /// version `0104` the `u32` at byte `0x20` is the black level in sample units (200 in the four 12-bit files here /// that have 200 as their darkest samples, 0 in the one whose samples reach 0). @@ -166,6 +173,13 @@ fn uncompressed(bytes: &[u8], info: &ImageInfo, order: ByteOrder, mn: Option<&ma /// Nikon Huffman-compressed strip (see [`super::nefc`]); the decode table is maker note `0x0096`. fn compressed(bytes: &[u8], info: &ImageInfo, mn: Option<&makernote::MakerNote>) -> Result { + let starts_with = |magic: &[u8]| { + let start = info.chunks(bytes.len() as u64).first().and_then(|c| usize::try_from(c.offset).ok()); + start.and_then(|o| bytes.get(o..o.checked_add(magic.len())?)) == Some(magic) + }; + if starts_with(&JPEG_XS_START) { + return Err(RawError::Unsupported(HIGH_EFFICIENCY.into())); + } // (some Z bodies label uncompressed, row-padded data 34713 without a table: not handled here yet) let table = mn .and_then(|m| Some((m.ifd.bytes(LINEARIZATION_TABLE)?, m.order))) @@ -354,6 +368,24 @@ mod tests { #[test] fn compressed_without_table_is_unsupported() { let bytes = nef(34713, 14, vec![vec![0; 64]], 8, 8, 8); - assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_)))); + let Err(RawError::Unsupported(why)) = crate::decode(&bytes) else { panic!("expected unsupported") }; + assert!(why.contains("linearization table"), "{why}"); + } + + /// Issue #193: a High Efficiency NEF (Z f, Z 8: compression 34713, no `0x0096` table, a JPEG XS codestream) is + /// named as such instead of being blamed on the missing table, and the header probe reports the same reason. + #[test] + fn high_efficiency_nef_is_named() { + let mut strip = vec![0xff, 0x10, 0xff, 0x50, 0x00, 0x22]; + strip.resize(64, 0); + let bytes = nef(34713, 14, vec![strip], 8, 8, 8); + for result in [crate::decode(&bytes).map(|_| ()), crate::probe_info(&bytes).map(|_| ())] { + let Err(RawError::Unsupported(why)) = result else { panic!("expected unsupported") }; + assert_eq!(why, HIGH_EFFICIENCY); + assert!(why.starts_with("Nikon High Efficiency NEF ("), "{why}"); + } + // a strip too short to hold the markers is still the missing-table case, not a panic + let short = nef(34713, 14, vec![vec![0xff, 0x10]], 1, 1, 1); + assert!(matches!(crate::decode(&short), Err(RawError::Unsupported(w)) if w.contains("linearization table"))); } } diff --git a/crates/raw/src/vendor/nefc.rs b/crates/raw/src/vendor/nefc.rs index 8ac9e73cd..8dab6b5dc 100644 --- a/crates/raw/src/vendor/nefc.rs +++ b/crates/raw/src/vendor/nefc.rs @@ -39,9 +39,18 @@ //! 252 in 12-bit units); with the first-interval step they sit at that black level. //! - Values in the maker note are in the maker note's byte order (newer bodies write little-endian notes). //! +//! "Lossy after split" (non-zero split row at `0x0096` offset 562, type 2 tables only, found in 11 of the CC0 +//! samples): derived by black-box analysis of those files. Rows below the split row use the lossy Huffman path +//! above unchanged. Row `split` starts at the very next bit (no alignment); from there every pixel is a word of +//! `bits - 4` bits (8 at 12 bit, 10 at 14 bit), MSB first, with no row padding. A word is a complete prefix code +//! for a difference category `n` followed by the top `W - prefix length` bits of the T.81 additional bits (the low +//! bits are dropped); the difference is the centre of the magnitude bin that was kept. The predictor is the same +//! as above and the running value is not clamped (only the sample index is, before the curve lookup). The camera +//! ends the strip with 0 to 7 one-bits after the last full row, which the decoder checks. +//! //! Not supported (returned as [`RawError::Unsupported`], the embedded preview is used instead): -//! - "Lossy after split" files (non-zero split row at `0x0096` offset 562): from that row on a different table -//! is used which our analysis has not recovered yet. +//! - "Lossy after split" files whose fixed-rate section does not end within the last byte of the strip (see +//! below): refused rather than decoded wrongly. //! - Other `0x0096` versions, bit depths other than 12/14. //! - Code words that never occurred in any sample (the lossy 12-bit 8-bit code word `11111110`, and code words //! longer than 9 bits in the lossy tables) are rejected as corrupt; they cannot code a difference within the @@ -102,6 +111,8 @@ pub(crate) struct DecodeTable { pub encoding: Encoding, /// Predictor seeds `[row parity][column]`. pub seeds: [[i32; 2]; 2], + /// First row of the fixed-rate section ("lossy after split"), 0 when the whole strip is Huffman coded. + pub split: usize, } /// Parse maker note `0x0096` for a `bits`-bit image. `order` is the maker note's byte order. @@ -116,6 +127,7 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result Encoding::Lossless, (0x44, 0x10 | 0x20 | 0x40) => { @@ -127,12 +139,7 @@ pub(crate) fn parse_table(t: &[u8], order: ByteOrder, bits: u32) -> Result Result return Err(RawError::Unsupported(format!("Nikon compressed NEF version {v0:#04x} {v1:#04x}"))), }; - Ok(DecodeTable { encoding, seeds }) + Ok(DecodeTable { encoding, seeds, split }) +} + +/// Difference-category prefix codes of the fixed-rate section: `(prefix bits, prefix length, category n)`. +/// A word of `W` bits is a prefix followed by `W - length` bits of the T.81 additional bits of category `n`. +/// (The 14-bit `010` = category 12 never occurs in the samples; its category is deduced.) +const FIXED_12: &[(u32, u32, u32)] = &[ + (0b00, 2, 9), + (0b010, 3, 10), + (0b011, 3, 8), + (0b100, 3, 7), + (0b101, 3, 6), + (0b110, 3, 5), + (0b1110, 4, 4), + (0b11110, 5, 3), + (0b111110, 6, 2), + (0b1111110, 7, 1), + (0b11111110, 8, 0), +]; +const FIXED_14: &[(u32, u32, u32)] = &[ + (0b00, 2, 8), + (0b010, 3, 12), + (0b011, 3, 11), + (0b100, 3, 10), + (0b101, 3, 9), + (0b110, 3, 7), + (0b1110, 4, 6), + (0b11110, 5, 5), + (0b111110, 6, 4), + (0b1111110, 7, 3), + (0b11111110, 8, 2), + (0b111111110, 9, 1), + (0b1111111110, 10, 0), +]; + +/// Word (`bits - 4` bits) to dequantised difference for the fixed-rate section; `None` marks the unused all-ones word. +fn fixed_word_table(bits: u32) -> Vec> { + let (w, codes) = if bits == 12 { (8, FIXED_12) } else { (10, FIXED_14) }; + let mut lut = vec![None; 1usize << w]; + for &(prefix, len, n) in codes { + let kept = w - len; + for m in 0..1u32 << kept { + let e = n.saturating_sub(kept); + let ex = (m << e) as i32; + let half = (1i32 << e) >> 1; + let d = if n == 0 { + 0 + } else if ex >= 1 << (n - 1) { + ex + half + } else if e > 0 { + ex + half - (1 << n) + } else { + ex - ((1 << n) - 1) + }; + if let Some(slot) = lut.get_mut(((prefix << kept) | m) as usize) { + *slot = Some(d); + } + } + } + lut } /// Decode a `w × h` Nikon Huffman-compressed strip `src` with `bits`-bit samples. @@ -183,6 +249,11 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa Encoding::Lossless => (&[], (1i32 << bits) - 1), Encoding::Lossy(curve) => (curve, curve.len() as i32 - 1), }; + if table.split != 0 && table.split >= h { + return Err(RawError::Corrupt(format!("NEF: split row {} outside the {h} rows", table.split))); + } + let fixed = if table.split != 0 { fixed_word_table(bits) } else { Vec::new() }; + let word = bits - 4; let mut out = vec![0u16; n]; let mut stream = Bits::new(src); let mut vpred = table.seeds; @@ -191,7 +262,12 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa let seeds = vpred.get_mut(y & 1).ok_or(RawError::Limit("NEF row"))?; let mut hpred = [0i32; 2]; for (x, o) in row.iter_mut().enumerate() { - let d = diff(&mut stream, &huff).ok_or_else(|| RawError::Corrupt(format!("NEF: invalid Huffman code at row {y}")))?; + let d = if table.split != 0 && y >= table.split { + let code = stream.get(word) as usize; + fixed.get(code).copied().flatten().ok_or_else(|| RawError::Corrupt(format!("NEF: invalid fixed-rate word at row {y}")))? + } else { + diff(&mut stream, &huff).ok_or_else(|| RawError::Corrupt(format!("NEF: invalid Huffman code at row {y}")))? + }; let p = if x < 2 { let s = seeds.get_mut(x).ok_or(RawError::Limit("NEF column"))?; *s += d; @@ -202,7 +278,7 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa hpred[x & 1] }; let v = p.clamp(0, max); - clipped += (v != p) as usize; + clipped += (v != p && (table.split == 0 || y < table.split)) as usize; *o = if lut.is_empty() { v as u16 } else { lut.get(v as usize).copied().unwrap_or(0) }; } // the camera pads the strip; reading more than a few bytes past its end means truncated or corrupt data @@ -210,6 +286,19 @@ pub(crate) fn decode(src: &[u8], w: usize, h: usize, bits: u32, table: &DecodeTa return Err(RawError::Corrupt(format!("NEF: compressed data ends at row {y} of {h}"))); } } + if table.split != 0 { + // the fixed-rate section must fill the rest of the strip: whole rows of w words and 0..7 bits of padding. + // A strip may hold a few more rows than the image (one body stores 4022 rows for 4020); anything else means + // the file does not follow the rule, so refuse it instead of decoding it wrongly. + let left = (src.len() * 8).checked_sub(stream.consumed_bits()); + let row_bits = w * word as usize; + if !left.is_some_and(|l| l % row_bits < 8 && l / row_bits <= 4) { + return Err(RawError::Unsupported(format!( + "Nikon \"lossy after split\" NEF whose fixed-rate section (from row {}) does not end within the last byte", + table.split + ))); + } + } // a wrong table / corrupt stream drifts out of range quickly; real files stay inside (a handful of edge pixels) if clipped > n / 1000 + 16 { return Err(RawError::Corrupt(format!("NEF: {clipped} decoded values out of range"))); @@ -293,7 +382,7 @@ mod tests { fn lossless(bits: u32) -> DecodeTable { let s = 1 << (bits - 3); - DecodeTable { encoding: Encoding::Lossless, seeds: [[s, s], [s, s]] } + DecodeTable { encoding: Encoding::Lossless, seeds: [[s, s], [s, s]], split: 0 } } #[test] @@ -417,6 +506,84 @@ mod tests { assert!(parse_table(&odd, ByteOrder::Little, 12).is_err()); } + /// Pack a string of `0`/`1` (spaces ignored) MSB first, padding the last byte with one-bits like the camera. + fn pack(bits: &str) -> Vec { + let b: Vec = bits.chars().filter(|c| !c.is_whitespace()).map(|c| c == '1').collect(); + b.chunks(8).map(|c| (0..8).fold(0u8, |a, i| (a << 1) | c.get(i).copied().unwrap_or(true) as u8)).collect() + } + + fn split_table(v1: u8, seed: u16, step: u16, white: u16, split: u16) -> DecodeTable { + let mut t = hand_table(v1, seed, step, white); + t[562..564].copy_from_slice(&split.to_le_bytes()); + parse_table(&t, ByteOrder::Little, if step == 4 { 12 } else { 14 }).unwrap() + } + + /// 12-bit "lossy after split": row 0 Huffman coded, rows 1 and 2 as 8-bit words that follow the last Huffman + /// bit directly. Bit strings written by hand from the rule (prefix, then the kept top bits of the extra bits). + const SPLIT_12: &str = "011 100 11110 1110 0 11110 11111110 11110100 11100111 01110000 00000001 11111110 01111111 11111110"; + + #[test] + fn lossy_after_split_known_answers_12_bit() { + let t = split_table(0x40, 252, 4, 4095, 1); + assert_eq!(t.split, 1); + let out = decode(&pack(SPLIT_12), 4, 3, 12, &t).unwrap(); + // row 1: d = 0 (n0), +4 (n3, exact), -8 (n4, exact), +132 (n8 keeps 5 bits: 16 << 3 plus half a bin, 4); + // row 2: -500 (n9 keeps 6 bits: bin 1 of the negative side, centre -508 + 8), then +252 on the running + // value -244 which is not clamped (8, not 252) + assert_eq!(out, [256, 252, 255, 252, 252, 256, 244, 388, 0, 252, 8, 252]); + } + + #[test] + fn lossy_after_split_known_answers_14_bit() { + let t = split_table(0x40, 1008, 16, 16383, 1); + // row 0 as in the 0x40 test above; row 1 in 10-bit words: 0 (n0), +32 (n6, exact), -254 (n8, exact), + // +510 (n9 keeps 7 bits: 127 << 2 plus 2) + let bits = "1100 100 111110 11110 0 111110 1111111110 1110100000 0000000001 1011111111"; + let out = decode(&pack(bits), 4, 2, 14, &t); + // word 3 is `00` + 8 bits: 00 00000001 -> n8 extra 00000001 = -254 + assert_eq!(out.unwrap(), [1012, 1008, 1011, 1008, 1008, 1040, 754, 1550]); + } + + #[test] + fn lossy_after_split_dequantisation_is_symmetric() { + let lut = fixed_word_table(12); + assert_eq!((lut[0b00_000000], lut[0b00_111111]), (Some(-508), Some(508))); + assert_eq!((lut[0b011_00000], lut[0b011_11111]), (Some(-252), Some(252))); + assert_eq!(lut[0b11111110], Some(0)); + assert_eq!(lut[0xff], None); + let lut = fixed_word_table(14); + assert_eq!((lut[0b00_00000000], lut[0b00_11111111]), (Some(-255), Some(255))); + assert_eq!(lut[0b101_0000000], Some(-510)); + assert_eq!(lut[0b1111111110], Some(0)); + assert_eq!(lut[0x3ff], None); + for bits in [12, 14] { + let lut = fixed_word_table(bits); + assert_eq!(lut.iter().filter(|d| d.is_none()).count(), 1, "{bits}-bit: only the all-ones word is unused"); + } + } + + #[test] + fn lossy_after_split_refuses_strips_that_do_not_follow_the_rule() { + let t = split_table(0x40, 252, 4, 4095, 1); + let mut longer = pack(SPLIT_12); + longer.extend_from_slice(&[0xff; 3]); + assert!(matches!(decode(&longer, 4, 3, 12, &t), Err(RawError::Unsupported(_)))); + let shorter = &pack(SPLIT_12)[..10]; + assert!(decode(shorter, 4, 3, 12, &t).is_err()); + // the unused all-ones word + let bad = pack("011 100 11110 1110 0 11110 11111111 11111110 11111110 11111110"); + assert!(matches!(decode(&bad, 4, 2, 12, &t), Err(RawError::Corrupt(_)))); + // a split row beyond the image + assert!(matches!(decode(&pack(SPLIT_12), 4, 1, 12, &t), Err(RawError::Corrupt(_)))); + // through the container + let mut table = hand_table(0x40, 252, 4, 4095); + table[562..564].copy_from_slice(&1u16.to_le_bytes()); + let ok = crate::decode(&nef_file(pack(SPLIT_12), 4, 3, 12, table.clone(), ByteOrder::Little)).unwrap(); + assert_eq!(ok.data, crate::RawData::U16(vec![256, 252, 255, 252, 252, 256, 244, 388, 0, 252, 8, 252])); + let e = crate::decode(&nef_file(longer, 4, 3, 12, table, ByteOrder::Little)); + assert!(matches!(e, Err(RawError::Unsupported(_))), "{e:?}"); + } + #[test] fn lossy_type1_full_curve() { let mut t = vec![0x44, 0x10, 0, 10, 0, 10, 0, 10, 0, 10, 0, 5]; @@ -436,7 +603,7 @@ mod tests { let corrupt = |r: Result| matches!(r, Err(RawError::Corrupt(_))); let mut split = lossy_table(12, ByteOrder::Big); split[562..564].copy_from_slice(&345u16.to_be_bytes()); - assert!(unsupported(parse_table(&split, ByteOrder::Big, 12))); + assert_eq!(parse_table(&split, ByteOrder::Big, 12).unwrap().split, 345); assert!(unsupported(parse_table(&[0x49, 0x30, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], ByteOrder::Big, 12))); assert!(unsupported(parse_table(&lossy_table(12, ByteOrder::Big), ByteOrder::Big, 16))); assert!(corrupt(parse_table(&[], ByteOrder::Big, 12))); @@ -502,11 +669,11 @@ mod tests { let src = encode(&img, w, &LOSSY_12, t.seeds); let r = crate::decode(&nef_file(src.clone(), w as u32, h as u32, 12, table.clone(), ByteOrder::Little)).unwrap(); assert_eq!(r.data, crate::RawData::U16(img.iter().map(|&v| curve[v as usize]).collect())); - // the split variant falls back to the preview + // a split file whose strip does not follow the fixed-rate rule is refused, never decoded wrongly let mut split = table; split[562..564].copy_from_slice(&3u16.to_le_bytes()); let e = crate::decode(&nef_file(src, w as u32, h as u32, 12, split, ByteOrder::Little)); - assert!(matches!(e, Err(RawError::Unsupported(_))), "{e:?}"); + assert!(e.is_err(), "{e:?}"); } #[test] diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index 893ee64a6..6d77925a0 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -10,6 +10,24 @@ //! four bits zero in more than 99% of samples), which we shift down. //! - 12-bit packed (XZ-2): each row is a sequence of little-endian 32-bit words read MSB-first (found by testing //! candidate bit orders for the smoothest image). +//! - 12-bit packed in two fields (a few old compacts): IFD0 says uncompressed, 12 bits, many strips, and the strip +//! byte counts add up to exactly width × height × 1.5. Samples are packed MSB-first, two per three bytes +//! (`b0 << 4 | b1 >> 4`, `(b1 & 15) << 8 | b2`), rows are `width × 1.5` bytes with no padding. Stored rows +//! `0..h0` are the even output rows and the rows from `h0` on the odd ones (`h0 = (height + 1) / 2`, height +//! odd); the strip table has one gap where the second field starts (follow the offsets) and omits the last row +//! of the second field, which sits in the bytes right after the last strip. Derived by black-box analysis of +//! six CC0 files (C5060WZ, C7070WZ, SP-510UZ, SP-550UZ, SP-565UZ, SP-570UZ). Their Exif `CFAPattern` states the +//! layout (it matches the embedded thumbnails), the maker note's entries `0x1017`/`0x1018` hold the red and +//! blue gain (first value, 256 = 1.0; checked against neutral areas of the thumbnail), and nothing states a +//! black level or an active area. +//! - 12-bit blocks (E-300, E-500, E-330; the E-M5 Mark II and PEN-F high-resolution files): one strip of 12.8 bits +//! per pixel, `width * 8 / 5` bytes per row, made of 16-byte blocks. A block is 15 sample bytes followed by one +//! pad byte that is always zero; the 15 bytes are a little-endian 120-bit string holding ten 12-bit samples, sample +//! `i` at bits `[12 i, 12 i + 12)`, i.e. per byte triple `s0 = b0 | (b1 & 15) << 8`, `s1 = b1 >> 4 | b2 << 4`, left +//! to right. The layout was first recalled only roughly ("ten pixels, then a skipped byte"); the pad position +//! (last), the bit order and the pixel order were fixed by measurement on the CC0 files 2878 (E-300), 3540 (E-500) +//! and 3624 (E-330), as was the sensor-origin RGGB anchoring (the maker-note crop offsets are odd). The E-M5 Mark II +//! and PEN-F files were only checked for smoothness, not for CFA anchoring or black level. //! - Olympus's compressed ORF (most interchangeable-lens bodies since ~2008) is not decoded: no permissively //! licensed description exists. It reports [`RawError::Unsupported`]; the embedded preview still works. //! - The colour-filter layout is the file's Exif `CFAPattern`: GRBG on the E-1 and E-400, RGGB on the XZ-2, where @@ -34,6 +52,8 @@ pub(crate) const PREVIEW_START: u16 = 0x0101; pub(crate) const PREVIEW_LENGTH: u16 = 0x0102; const WB_RB: u16 = 0x0100; const BLACK: u16 = 0x0600; +const MN_WB_RED: u16 = 0x1017; +const MN_WB_BLUE: u16 = 0x1018; const CROP: [u16; 4] = [0x0612, 0x0613, 0x0614, 0x0615]; /// The Olympus maker note. @@ -68,6 +88,30 @@ pub(crate) fn unpack_row_le32_msb(src: &[u8], bits: u32, out: &mut [u16]) { unpack_msb(&swapped, bits, out); } +/// Unpack one row of 12-bit samples stored as 16-byte blocks: ten little-endian 12-bit fields in bytes 0..15, byte 15 +/// a pad. `src` must hold exactly `out.len() / 10` blocks (`out.len()` a multiple of 10). +pub(crate) fn unpack_row_blocks16(src: &[u8], out: &mut [u16]) -> Result<()> { + if !out.len().is_multiple_of(10) || src.len() != out.len() / 10 * 16 { + return Err(RawError::Corrupt("ORF 12-bit block row has the wrong size".into())); + } + let (blocks, _) = src.as_chunks::<16>(); + let (pixels, _) = out.as_chunks_mut::<10>(); + for (block, px) in blocks.iter().zip(pixels) { + for k in 0..5 { + let (b0, b1, b2) = (block[3 * k] as u16, block[3 * k + 1] as u16, block[3 * k + 2] as u16); + px[2 * k] = b0 | (b1 & 15) << 8; + px[2 * k + 1] = b1 >> 4 | b2 << 4; + } + } + Ok(()) +} + +/// Whether the strip is the 12.8 bits per pixel block layout: a single strip, rows a whole number of 16-byte +/// blocks of ten pixels. +fn is_block16(w: usize, n: usize, total: u64, chunks: usize) -> bool { + chunks == 1 && w.is_multiple_of(10) && total.checked_mul(10) == (n as u64).checked_mul(16) +} + /// The layout found from the samples, for files without the Exif tag: GRBG when the greens sit on the main /// diagonal of the 2×2 cell at the sensor origin, else RGGB. Over 32×32-pixel blocks of `a` (every fourth in both /// directions) it compares the block totals of the two sites on each diagonal: the two greens of a block see the @@ -98,6 +142,50 @@ pub(crate) fn cfa_from_data(d: &[u16], w: usize, a: Rect) -> Cfa { Cfa::bayer_static(if main < anti { "GRBG" } else { "RGGB" }) } +/// Byte offsets of the stored rows of the two-field 12-bit layout, or `None` unless every condition holds +/// exactly: odd height, even width, `strips` (offset, byte count; the whole table, not just the strips that fit the +/// rows-per-strip grid, since the field seam leaves a short strip) that are whole rows summing to +/// `width × height × 1.5` bytes, exactly one gap between consecutive strips (the second field starts right after +/// it, at row `(height + 1) / 2`), and a last row, right after the final strip, inside the file. Returns +/// `height + 1` offsets. +fn field_rows(strips: &[(u64, u64)], w: usize, h: usize, file_len: usize) -> Option> { + let rb = w.checked_mul(3)? / 2; + if rb == 0 || !w.is_multiple_of(2) || h.is_multiple_of(2) || h < 3 || strips.len() < 2 { + return None; + } + let h0 = h.div_ceil(2); + let total = strips.iter().try_fold(0u64, |a, s| a.checked_add(s.1))?; + if total != (rb as u64).checked_mul(h as u64)? { + return None; + } + let mut rows = Vec::with_capacity(h + 1); + let mut gaps = 0; + let mut end = None; + for &(offset, len) in strips { + let next_end = offset.checked_add(len).filter(|e| len > 0 && len % rb as u64 == 0 && *e <= file_len as u64)?; + match end { + Some(e) if offset < e => return None, + // the gap must be the field seam + Some(e) if offset > e => { + if rows.len() != h0 { + return None; + } + gaps += 1; + } + _ => {} + } + end = Some(next_end); + let (offset, rb) = (usize::try_from(offset).ok()?, rb); + rows.extend((0..usize::try_from(len).ok()? / rb).map(|r| offset + r * rb)); + } + let tail = usize::try_from(end?).ok()?; + if gaps != 1 || rows.len() != h || tail.checked_add(rb)? > file_len { + return None; + } + rows.push(tail); + Some(rows) +} + pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let tiff = Tiff::parse(bytes)?; let ifd0 = &tiff.ifds[0]; @@ -107,11 +195,26 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { let chunks = info.chunks(bytes.len() as u64); let total: u64 = chunks.iter().map(|c| c.len).sum(); let stated = cfa_from_exif(&tiff); + let mut two_field = false; let (mut data, bits) = if info.compression != 1 { return Err(RawError::Unsupported(format!("ORF compression {}", info.compression))); } else if total >= (n as u64) * 2 { let d = read_image(bytes, &info, tiff.order, Packing::Word16)?; (d, 16) + } else if is_block16(w, n, total, chunks.len()) { + let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?; + let stride = w / 10 * 16; + if src.len() as u64 != total || src.len() / stride != h { + return Err(RawError::Corrupt("ORF strip shorter than its rows".into())); + } + let d = if mode == Mode::Full || stated.is_none() { + let mut d = vec![0u16; n]; + d.par_chunks_mut(w).enumerate().try_for_each(|(y, row)| unpack_row_blocks16(&src[y * stride..(y + 1) * stride], row))?; + d + } else { + Vec::new() + }; + (RawData::U16(d), 12) } else if total * 8 >= (n as u64) * 12 && total * 8 < (n as u64) * 13 && chunks.len() == 1 { let src = chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("ORF strip outside file".into()))?; // the depth is fixed here, so a header-only probe needs the samples only when the file doesn't state its @@ -125,6 +228,26 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { Vec::new() }; (RawData::U16(d), 12) + } else if let Some(rows) = (info.bits() == 12 && info.samples_per_pixel == 1 && info.planar != 2 && info.offsets.len() == info.byte_counts.len()) + .then(|| field_rows(&info.offsets.iter().copied().zip(info.byte_counts.iter().copied()).collect::>(), w, h, bytes.len())) + .flatten() + { + two_field = true; + let d = if mode == Mode::Full || stated.is_none() { + let (rb, h0) = (w * 3 / 2, h.div_ceil(2)); + let mut d = vec![0u16; n]; + d.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let stored = if y % 2 == 0 { y / 2 } else { h0 + y / 2 }; + // every offset was checked against the file by `field_rows` + if let Some(src) = rows.get(stored).and_then(|&at| bytes.get(at..at + rb)) { + unpack_msb(src, 12, row); + } + }); + d + } else { + Vec::new() + }; + (RawData::U16(d), 12) } else { return Err(RawError::Unsupported("Olympus compressed ORF".into())); }; @@ -165,7 +288,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { .as_ref() .and_then(|i| i.f64s(WB_RB)) .filter(|v| v.len() >= 2 && v[0] > 0.0 && v[1] > 0.0) - .map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32]); + .map(|v| [(v[0] / 256.0) as f32, 1.0, (v[1] / 256.0) as f32]) + .or_else(|| { + // two-field bodies: the red and blue gains lead the entries of the main maker note + let m = mn.as_ref().filter(|_| two_field)?; + let g = |tag| m.ifd.f64s(tag).and_then(|v| v.first().copied()).filter(|g| *g > 0.0); + Some([(g(MN_WB_RED)? / 256.0) as f32, 1.0, (g(MN_WB_BLUE)? / 256.0) as f32]) + }); let white = white_from_data(samples, bits); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(active.width as u32); @@ -337,6 +466,104 @@ mod tests { } } + /// Output sample (x, y) of the two-field tests: distinct per site, all below 4096. + fn field_px(x: usize, y: usize) -> u16 { + ((y * 331 + x * 17 + 5) % 4096) as u16 + } + + /// 12 bits MSB-first, two samples per three bytes, for output row `y`. + fn packed_row(w: usize, y: usize) -> Vec { + (0..w) + .step_by(2) + .flat_map(|x| { + let (a, b) = (field_px(x, y), field_px(x + 1, y)); + [(a >> 4) as u8, ((a & 15) << 4 | b >> 8) as u8, b as u8] + }) + .collect() + } + + /// A two-field file: strips A1 (two rows), A2 (one row), a gap, B (all but the last row of the second field), + /// with the last row of the second field right after B, as the strip table of the old compacts has it (width 8, + /// height 5). `table_gap` keeps the gap in the table (else the strips are contiguous and the table is wrong). + fn two_field(table_gap: bool) -> Vec { + let (w, h) = (8usize, 5usize); + let h0 = h.div_ceil(2); + // stored rows: the even output rows, then the odd ones (the last of them is past the picture) + let stored: Vec> = (0..h0).map(|k| packed_row(w, 2 * k)).chain((0..h0).map(|k| packed_row(w, 2 * k + 1))).collect(); + let a1 = stored[..2].concat(); + let a2 = stored[2].clone(); + let gap = vec![0xffu8; 20]; + let b = stored[3].clone(); + let b2 = stored[4].clone(); + let last = stored[5].clone(); + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w as u32])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h as u32])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::MAKE, Value::Ascii("OLYMPUS OPTICAL CO.,LTD".into())); + ifd.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![a1, a2, gap, b, b2, last] }); + let mut exif = IfdBuilder::new(); + exif.set(EXIF_CFA_PATTERN, Value::Undefined(BGGR.to_vec())); + ifd.set_child(t::EXIF_IFD, exif); + let mut b = TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).unwrap(); + b[2] = b'R'; + b[3] = b'S'; + // keep strips A1, A2, B1 and B2 in the table (drop the gap and the last row): four strips for a grid of three + let le = |b: &[u8], at: usize| u32::from_le_bytes(b[at..at + 4].try_into().unwrap()); + let at0 = le(&b, 4) as usize; + let n = u16::from_le_bytes([b[at0], b[at0 + 1]]) as usize; + for e in (0..n).map(|i| at0 + 2 + 12 * i) { + let tag = u16::from_le_bytes([b[e], b[e + 1]]); + if tag != t::STRIP_OFFSETS && tag != t::STRIP_BYTE_COUNTS { + continue; + } + let at = le(&b, e + 8) as usize; + let all: Vec = (0..6).map(|i| le(&b, at + 4 * i)).collect(); + let keep: Vec = if table_gap { vec![all[0], all[1], all[3], all[4]] } else { vec![all[0], all[1], all[2], all[3]] }; + b[e + 4..e + 8].copy_from_slice(&(keep.len() as u32).to_le_bytes()); + for (i, v) in keep.iter().enumerate() { + b[at + 4 * i..at + 4 * i + 4].copy_from_slice(&v.to_le_bytes()); + } + } + b + } + + #[test] + fn two_field_12_bit_layout() { + let bytes = two_field(true); + let r = crate::decode(&bytes).unwrap(); + let want: Vec = (0..5).flat_map(|y| (0..8).map(move |x| field_px(x, y))).collect(); + assert_eq!((r.data.clone(), r.bits, r.width, r.height), (RawData::U16(want), 12, 8, 5)); + assert_eq!(r.cfa.as_ref().unwrap().name(), "BGGR"); + assert_eq!(crate::probe_info(&bytes).unwrap(), r.info()); + // the table without the gap: sizes no longer add up + let e = crate::decode(&two_field(false)).map(|_| ()); + assert!(matches!(e, Err(RawError::Unsupported(_))), "{e:?}"); + } + + #[test] + fn two_field_layout_needs_every_condition() { + let chunk = |_index: usize, offset: u64, rows: u64| (offset, rows * 12); + // width 8, height 5: rows 0..3 are the first field, the gap, rows 3..5, the last row after them + let good = [chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 200, 2)]; + let rows = field_rows(&good, 8, 5, 236).unwrap(); + assert_eq!(rows, [100, 112, 124, 200, 212, 224]); + // the last row must be inside the file + assert!(field_rows(&good, 8, 5, 235).is_none()); + // the gap is the field seam: not after two rows, not absent, not twice + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 140, 1), chunk(2, 164, 2)], 8, 5, 300).is_none()); + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 136, 2)], 8, 5, 300).is_none()); + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 200, 1), chunk(3, 300, 1)], 8, 5, 400).is_none()); + // overlapping strips, sizes that don't add up, even height, odd width, a single strip + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 120, 1), chunk(2, 200, 2)], 8, 5, 300).is_none()); + assert!(field_rows(&[chunk(0, 100, 2), chunk(1, 124, 1), chunk(2, 200, 1)], 8, 5, 300).is_none()); + assert!(field_rows(&good, 8, 4, 300).is_none()); + assert!(field_rows(&good, 7, 5, 300).is_none()); + assert!(field_rows(&good[..1], 8, 5, 300).is_none()); + } + #[test] fn word16_shifted_and_packed12() { let (w, h) = (16usize, 4usize); @@ -358,4 +585,71 @@ mod tests { let bytes = orf(w as u32, h as u32, 16, vec![0; 40]); assert!(matches!(crate::decode(&bytes), Err(RawError::Unsupported(_)))); } + + /// Pack samples into 16-byte blocks: ten little-endian 12-bit fields, then a zero pad byte. + fn pack_blocks(samples: &[u16]) -> Vec { + samples + .chunks(10) + .flat_map(|c| { + let mut v = 0u128; + for (i, s) in c.iter().enumerate() { + v |= (*s as u128 & 0xfff) << (12 * i); + } + let mut b = v.to_le_bytes(); + b[15] = 0; + b + }) + .collect() + } + + #[test] + fn block16_round_trip() { + let (w, h) = (20usize, 4usize); + let samples: Vec = (0..w * h).map(|i| ((i * 509 + 17) % 4096) as u16).collect(); + let strip = pack_blocks(&samples); + assert_eq!(strip.len(), w * h * 16 / 10); + let mut out = vec![0u16; w * h]; + for (y, row) in out.chunks_mut(w).enumerate() { + unpack_row_blocks16(&strip[y * 32..(y + 1) * 32], row).unwrap(); + } + assert_eq!(out, samples); + // a known block: s0 = b0 | (b1 & 15) << 8, s1 = b1 >> 4 | b2 << 4 + let mut px = [0u16; 10]; + let mut blk = [0u8; 16]; + blk[..3].copy_from_slice(&[0x21, 0x43, 0x65]); + unpack_row_blocks16(&blk, &mut px).unwrap(); + assert_eq!(&px[..2], &[0x321, 0x654]); + // the whole file through the decoder + let bytes = orf_with(w as u32, h as u32, 16, strip, Some(RGGB)); + let r = crate::decode(&bytes).unwrap(); + assert_eq!(r.bits, 12); + let RawData::U16(d) = &r.data else { panic!() }; + assert_eq!(d, &samples); + } + + #[test] + fn block16_routing() { + assert!(is_block16(3360, 3360 * 2504, 13461504, 1)); + assert!(!is_block16(3360, 3360 * 2504, 3360 * 2504 * 12 / 8, 1)); // XZ-2: exactly 12.0 bpp + assert!(!is_block16(3360, 3360 * 2504, 13461504, 2)); + assert!(!is_block16(3365, 3365 * 100, 3365 * 100 * 16 / 10, 1)); + // the 12.0 bpp layout still decodes through its own branch + let bytes = orf_with(8, 2, 12, vec![0u8; 8 * 2 * 12 / 8], Some(RGGB)); + assert!(crate::decode(&bytes).is_ok()); + } + + #[test] + fn block16_short_input_is_an_error() { + let mut px = [0u16; 10]; + assert!(unpack_row_blocks16(&[0u8; 15], &mut px).is_err()); + assert!(unpack_row_blocks16(&[0u8; 32], &mut px).is_err()); + assert!(unpack_row_blocks16(&[], &mut px).is_err()); + let mut odd = [0u16; 7]; + assert!(unpack_row_blocks16(&[0u8; 16], &mut odd).is_err()); + // a truncated file never panics + let bytes = orf_with(20, 4, 16, pack_blocks(&[5u16; 80]), Some(RGGB)); + for cut in [bytes.len() - 1, bytes.len() - 40, bytes.len() / 2, 100] { + let _ = crate::decode(&bytes[..cut]); + } + } } diff --git a/crates/raw/src/vendor/sr2.rs b/crates/raw/src/vendor/sr2.rs new file mode 100644 index 000000000..28bf60ed1 --- /dev/null +++ b/crates/raw/src/vendor/sr2.rs @@ -0,0 +1,301 @@ +//! Sony's encrypted `SR2SubIFD`. +//! +//! Sony ARWs from the DSLR-A200 on carry a private directory, the `SR2SubIFD`, reached from IFD0's +//! `DNGPrivateData` through the `SR2Private` IFD (`SR2SubIFDOffset` `0x7200`, `SR2SubIFDLength` `0x7201`, +//! `SR2SubIFDKey` `0x7221`; tag names from ExifTool's Sony tag documentation). The block is a TIFF directory in the +//! file's byte order whose value offsets are file offsets into the block itself, and the whole block is encrypted. +//! Among its entries are the black level (`0x7310`) and the white balance the camera applied, `WB_RGGBLevels` +//! (`0x7313`: R, G, G, B); bodies from about 2017 on also write both in plain form in the raw IFD. +//! +//! This module is the only place the block is decrypted: the black level and the white balance are both read through +//! [`SubIfd`] (issue #535; it replaces positional keystream tables recovered earlier for the black level alone). +//! +//! **Clean-room provenance.** The decryption was derived only by black-box known-plaintext analysis of CC0 files. No +//! raw-decoder source (dcraw, LibRaw, rawspeed, rawloader/rawler, darktable, RawTherapee, ExifTool's Perl code, …) +//! and no description of Sony's cipher or keystream generator was read or consulted. The steps: +//! +//! 1. **Files.** The 126 ARWs with an `SR2SubIFD` among the CC0 Sony samples of raw.pixls.us collected for issue +//! #535: 97 bodies from the DSLR-A200 to the ILCE-9M3 and ZV-E1, every Sony body on the site that has one, with +//! every coding of the bodies named in open issues. +//! 2. **Plaintext.** `exiftool -v5 FILE`, run as a black-box tool, prints the decrypted `SR2SubIFD` as a hex dump +//! with file offsets. XOR-ing each dumped byte with the byte at the same offset in the file gives the keystream +//! byte at that position of the block. +//! 3. **Comparison.** All 126 files store the key `11 22 33 44`, and their keystreams agree byte for byte at every +//! position they share: 15 layouts from 25150 to 62112 bytes, 5.2 MB of keystream in all. Bytes after the last +//! whole 4-byte word (two, in every block whose length is not a multiple of four) are stored in the clear. +//! 4. **Structure.** Read as 32-bit words, the longest keystream (the DSLR-A700 sample `DSC07249.ARW`, SHA-256 +//! `3159e288…`, 15528 words) was tested for every recurrence `k[n] = k[n − a] ⊕ k[n − b]` with +//! `1 ≤ a < b < 300`: an exhaustive search, not a guided one. Exactly two pairs hold on every word: `(63, 127)` +//! and `(126, 254)`, the second a consequence of the first. With `(63, 127)`, any 127 consecutive words fix all +//! later ones, so [`SEED`] is simply the first 127 words of that file's keystream. +//! 5. **Checks.** Generated from [`SEED`], the keystream equals the one recovered from each of the 126 files at every +//! position. It also reproduces bytes recovered independently from plain tag values before the generator existed +//! (8 bytes at position 2510, issue #148, from the plain black levels of the DSC-RX100M6 and ILCE-7M3) and the +//! positional tables the black level was read with until issue #535; the unit tests pin all of them. +//! +//! Only that key is supported. The one other key seen, in a DSC-R1 SR2 file, gives a different keystream that obeys +//! the same recurrence from its own first 127 words, but one sample per key cannot show how a seed follows from a +//! key, so files with another key get `None`. + +use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t}; + +/// `SR2Private` tags. +const SUBIFD_OFFSET: u16 = 0x7200; +const SUBIFD_LENGTH: u16 = 0x7201; +const SUBIFD_KEY: u16 = 0x7221; +/// The key stored by every ARW examined; [`SEED`] is its keystream. +const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; +/// Upper bound on the block length we decrypt. The largest seen is 62112 bytes (DSLR-A700). +const MAX_LEN: usize = 1 << 20; +/// TIFF field types of the levels: SHORT and SSHORT. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; + +/// The first 127 keystream words for [`KEY`] (each XOR-ed with four block bytes, most significant byte first). +/// Later words follow `k[n] = k[n − 63] ⊕ k[n − 127]`; see the module docs. +const SEED: [u32; 127] = [ + 0x54c2c4d6, 0x49c478bc, 0x34ae2f8e, 0x25d80504, 0xc0d9d6b0, 0xd838fb71, 0xe8eff27d, 0xfbc1fcea, 0x506c499a, 0x47f20f37, 0x710777cf, 0x7867e7ba, + 0x42d67caa, 0x7f2bd11a, 0x67a216ca, 0x0e986d40, 0x4ae8d4c0, 0xe36778b4, 0x5a958415, 0xdbfe2be8, 0x20faa1aa, 0x7132a6b8, 0xf4de4b7f, 0x55991aa1, + 0xa849d5aa, 0x49577832, 0xb92f3daa, 0x399cc526, 0x22cdd000, 0xe1977a29, 0x37c5db55, 0xb0177e1e, 0x2a1016aa, 0xa300086e, 0x3bab9bfe, 0x262eece0, + 0x23771aa9, 0x0a5dc91c, 0x31b902ae, 0x58e64bf8, 0x259c300e, 0xa57705c8, 0x284a6541, 0xfb229c60, 0x1bacaa9e, 0xbcab3350, 0x67cd9fbe, 0x8f135e60, + 0xf8c26a40, 0x6770da61, 0x3e1febfd, 0xd0c70803, 0x8dbb037b, 0x6f6fa4c5, 0x6749d10d, 0x7f51598d, 0xd5e5a4ec, 0x207dfa91, 0x6558ebc2, 0xbe594639, + 0x617a9e5d, 0x3c497950, 0x0844eb3f, 0x04207ed2, 0xd27ceac4, 0x70d20f05, 0xb47003f6, 0xe9e4e3ae, 0xcc19d265, 0x326dd956, 0xf0d3a327, 0xb71275f0, + 0x7994e285, 0x0aff594d, 0x128e8345, 0x7bda597a, 0xd634c380, 0xe24a006f, 0x8974818b, 0x3320b22a, 0xbe808417, 0xa2d5648a, 0x6fe80b39, 0x23ebad41, + 0xa2d11e5d, 0x027d9397, 0x9a722ac8, 0x432c7dac, 0x7146692a, 0x82a3dc77, 0xd66887c5, 0x831f43b7, 0x4e5dddde, 0x03793f81, 0x306ab437, 0x00ccf86c, + 0xfc6ed3d2, 0x076b8fdb, 0x9808cfca, 0x0f4eef6e, 0xc8cc3830, 0x104ac16a, 0xa189eff4, 0x3e085c08, 0xd28baf88, 0x5c853ac4, 0xe60480f8, 0xc51acd98, + 0x691e5ee1, 0x333feeb9, 0x1e35bc33, 0xec4a4642, 0xee57c5a5, 0xbeeb51f7, 0xe0c4f32c, 0xa5422f6a, 0x1d266d12, 0x3752fd3b, 0xfbc53c7d, 0x2421a4a3, + 0xcdc6a2de, 0x26e6b330, 0x6c073d46, 0x058e2f27, 0x43833f30, 0x46d1382e, 0x5f0804ec, +]; + +/// XOR `block` with the keystream from its first byte on (whole 4-byte words; a shorter tail stays as it is). +/// Encrypts and decrypts alike. +fn apply_keystream(block: &mut [u8]) { + let mut ring = SEED; + for (n, word) in block.as_chunks_mut::<4>().0.iter_mut().enumerate() { + // `ring[i]` holds k[n − 127] until it is replaced by k[n]; k[n − 63] sits at (n − 63) mod 127 + let i = n % SEED.len(); + if n >= SEED.len() { + ring[i] ^= ring[(i + 64) % SEED.len()]; + } + for (b, k) in word.iter_mut().zip(ring[i].to_be_bytes()) { + *b ^= k; + } + } +} + +/// A decrypted `SR2SubIFD`. +pub(crate) struct SubIfd { + /// File offset of the block: the directory's value offsets are file offsets. + start: usize, + plain: Vec, + order: ByteOrder, +} + +impl SubIfd { + /// Find and decrypt the file's `SR2SubIFD`; `None` when it has none, it lies outside the file, is larger than + /// [`MAX_LEN`] or uses another key than [`KEY`]. + pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option { + let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; + let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?; + if private.bytes(SUBIFD_KEY)? != KEY { + return None; + } + let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?; + let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?; + if len > MAX_LEN { + return None; + } + Some(Self::decrypt(bytes.get(start..start.checked_add(len)?)?, start, order)) + } + + /// Decrypt `block`, the encrypted directory found at file offset `start`. + pub(crate) fn decrypt(block: &[u8], start: usize, order: ByteOrder) -> Self { + let mut plain = block.to_vec(); + apply_keystream(&mut plain); + Self { start, plain, order } + } + + /// The byte order of the directory's values (the file's). + pub(crate) fn order(&self) -> ByteOrder { + self.order + } + + /// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type + /// or its values lie outside the block. + pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> { + let o = self.order; + let entries = usize::from(o.read_u16(&self.plain, 0)?); + let entry = (0..entries).map_while(|i| self.plain.get(2 + 12 * i..14 + 12 * i)).find(|e| o.read_u16(e, 0) == Some(tag))?; + let kind = o.read_u16(entry, 2)?; + if kind != SHORT && kind != SSHORT { + return None; + } + let size = usize::try_from(o.read_u32(entry, 4)?).ok()?.checked_mul(2)?; + let values = if size <= 4 { + entry.get(8..8 + size)? + } else { + let at = usize::try_from(o.read_u32(entry, 8)?).ok()?.checked_sub(self.start)?; + self.plain.get(at..at.checked_add(size)?)? + }; + Some((kind, values)) + } + + /// The values of a SHORT or SSHORT entry (see [`SubIfd::short_bytes`]). + pub(crate) fn shorts(&self, tag: u16) -> Option> { + let (kind, values) = self.short_bytes(tag)?; + let value = |v: u16| if kind == SSHORT { f64::from(v as i16) } else { f64::from(v) }; + Some(values.as_chunks::<2>().0.iter().map(|&c| value(self.order.u16(c))).collect()) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use lightcraft_tiff::ByteOrder::{Big, Little}; + + /// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values + /// of more than two shorts stored after the table at file offsets, as Sony does. + pub(crate) fn block(start: usize, entries: &[(u16, u16, &[u16])], order: ByteOrder) -> Vec { + let mut plain = Vec::new(); + order.put_u16(&mut plain, entries.len() as u16); + let mut data = Vec::new(); + let data_at = start + 2 + 12 * entries.len() + 4; + for &(tag, kind, values) in entries { + order.put_u16(&mut plain, tag); + order.put_u16(&mut plain, kind); + order.put_u32(&mut plain, values.len() as u32); + let mut field = Vec::new(); + values.iter().for_each(|&v| order.put_u16(&mut field, v)); + if field.len() <= 4 { + field.resize(4, 0); + plain.extend(field); + } else { + order.put_u32(&mut plain, (data_at + data.len()) as u32); + data.extend(field); + } + } + plain.extend([0; 4]); + plain.extend(data); + plain.extend([0xa5; 2]); // a length that is not a multiple of four, like most real blocks + apply_keystream(&mut plain); + plain + } + + /// Encrypt (or decrypt) `block` in place, as a file stores an `SR2SubIFD` at its first byte. + pub(crate) fn encrypt(block: &mut [u8]) { + apply_keystream(block); + } + + /// Keystream bytes recovered earlier, each from plain tag values or ExifTool's decrypted dump at one position, + /// before the generator existed: the black level's 8 bytes at 2510 (#148), and the positional tables the black + /// level was read with until #535 (#542: the directory's count and first entry, and the black-level value in the + /// four layouts of bodies without a plain `0x7310`). The generator must reproduce every one of them, so the black + /// levels it decrypts are those the tables gave. + #[test] + fn keystream_matches_bytes_recovered_at_single_positions() { + let mut ks = vec![0u8; 62114]; + apply_keystream(&mut ks); + let recovered: [(usize, &[u8]); 5] = [ + (0, &[0x54, 0xc2, 0xc4, 0xd6, 0x49, 0xc4, 0x78, 0xbc, 0x34, 0xae, 0x2f, 0x8e, 0x25, 0xd8]), + (1638, &[0x18, 0x95, 0x65, 0xf0, 0x18, 0x8f, 0x61, 0xc6]), + (2166, &[0xc6, 0x17, 0x02, 0xcb, 0x89, 0x21, 0xbc, 0x43]), + (2418, &[0xd1, 0xef, 0xda, 0xfe, 0x50, 0xf3, 0xd0, 0xbe]), + (2510, &[0x43, 0xcb, 0x86, 0xb6, 0x11, 0xd2, 0x1a, 0x73]), + ]; + for (at, bytes) in recovered { + assert_eq!(&ks[at..at + bytes.len()], bytes, "keystream at {at}"); + } + } + + /// Known answers recorded from CC0 files without the generator, so that a wrong seed word or recurrence fails + /// here even without the corpus: the SHA-256 of the whole keystream recovered from the DSLR-A700 sample + /// (`DSC07249.ARW`: the file's bytes XOR ExifTool's decrypted dump; 62112 bytes, the longest block seen, so it + /// covers every black-level and white-balance position of every layout), and the encrypted `WB_RGGBLevels` of + /// one file per layout of bodies without a plain `0x7313`, with the levels ExifTool decrypts from them. + #[test] + fn keystream_matches_recorded_known_answers() { + use sha2::{Digest, Sha256}; + let mut ks = vec![0u8; 62112]; + apply_keystream(&mut ks); + let digest: String = Sha256::digest(&ks).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(digest, "d55e96d7bb69cb823d9dfa038dd9f3cfb6b080be4df8c8aa2bc56f71a4a3f183"); + // (file, position in the block, bytes stored there, WB_RGGBLevels) + let cases: [(&str, usize, [u8; 8], [i16; 4]); 4] = [ + ("DSLR-A700 DSC07249", 1654, [0xc9, 0x2a, 0xb6, 0xa4, 0x1f, 0xc1, 0x72, 0x30], [2128, 1024, 1024, 1564]), + ("DSLR-A500 DSC02421", 2182, [0xb9, 0x6a, 0xed, 0xd3, 0x0d, 0xa8, 0xbb, 0xda], [2212, 1024, 1024, 1416]), + ("SLT-A33 DSC01867", 2434, [0x3c, 0x43, 0x05, 0x3f, 0x0d, 0xe2, 0x05, 0xcc], [2344, 1024, 1024, 1508]), + ("ILCE-3500 DSC06923", 2526, [0x37, 0x2e, 0x71, 0x5b, 0x88, 0xb7, 0x21, 0x73], [2932, 1024, 1024, 1576]), + ]; + for (file, at, stored, levels) in cases { + let mut block = vec![0u8; (at + 8).next_multiple_of(4)]; + block[at..at + 8].copy_from_slice(&stored); + apply_keystream(&mut block); + let got: Vec = block[at..at + 8].chunks(2).map(|c| i16::from_le_bytes([c[0], c[1]])).collect(); + assert_eq!(got, levels, "{file}"); + } + } + + #[test] + fn keystream_follows_its_recurrence() { + let mut zeros = vec![0u8; 62114]; + apply_keystream(&mut zeros); + // the recurrence, past the seed, and the clear tail + let word = |n: usize| u32::from_be_bytes(zeros[4 * n..4 * n + 4].try_into().unwrap()); + assert_eq!(word(127), SEED[64] ^ SEED[0]); + assert!((200..15528).all(|n| word(n) == word(n - 63) ^ word(n - 127))); + assert_eq!(zeros[62112..], [0, 0]); + // applying it twice restores the input + let mut again = zeros.clone(); + apply_keystream(&mut again); + assert!(again[..62112].iter().all(|&b| b == 0)); + } + + #[test] + fn reads_entries_inline_and_at_file_offsets() { + for order in [Little, Big] { + let start = 75964; + let enc = block(start, &[(0x7310, SHORT, &[512; 4]), (0x7311, SHORT, &[256]), (0x7313, SSHORT, &[2212, 1024, 1024, 1416])], order); + let sub = SubIfd::decrypt(&enc, start, order); + assert_eq!(sub.shorts(0x7313), Some(vec![2212.0, 1024.0, 1024.0, 1416.0]), "{order:?}"); + assert_eq!(sub.shorts(0x7310), Some(vec![512.0; 4])); + assert_eq!(sub.shorts(0x7311), Some(vec![256.0])); + assert_eq!(sub.shorts(0x7312), None); + // SSHORT is signed + let enc = block(start, &[(0x7313, SSHORT, &[0xffff, 1024])], order); + assert_eq!(SubIfd::decrypt(&enc, start, order).shorts(0x7313), Some(vec![-1.0, 1024.0])); + } + } + + #[test] + fn hostile_directories_give_none() { + let start = 4096; + let good = block(start, &[(0x7313, SSHORT, &[2212, 1024, 1024, 1416])], Little); + let read = |b: &[u8], start: usize| SubIfd::decrypt(b, start, Little).shorts(0x7313); + assert!(read(&good, start).is_some()); + // decrypted at another file offset, the value offset points before or past the block + assert_eq!(read(&good, start + 64), None); + assert_eq!(read(&good, start - 64), None); + // every truncation, down to empty + for n in 0..good.len() - 2 { + assert_eq!(read(&good[..n], start), None, "{n} bytes"); + } + // garbage: still encrypted; 65535 entries of tag 0xffff; a huge count, another type, a wild offset + assert_eq!(SubIfd { start, plain: good.clone(), order: Little }.shorts(0x7313), None); + assert_eq!(SubIfd { start, plain: vec![0xff; 1000], order: Little }.shorts(0x7313), None); + let mut plain = good.clone(); + apply_keystream(&mut plain); + let patched = |at: usize, bytes: &[u8]| { + let mut p = plain.clone(); + p[at..at + bytes.len()].copy_from_slice(bytes); + apply_keystream(&mut p); + read(&p, start) + }; + assert_eq!(patched(6, &u32::MAX.to_le_bytes()), None); // count + assert_eq!(patched(4, &4u16.to_le_bytes()), None); // LONG + assert_eq!(patched(10, &u32::MAX.to_le_bytes()), None); // value offset + // an entry count past the end of the block: the scan stops where the block ends, so an entry before that + // point is still found + assert_eq!(patched(0, &u16::MAX.to_le_bytes()), Some(vec![2212.0, 1024.0, 1024.0, 1416.0])); + } +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index 5ce32565e..408c670f1 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -17,6 +17,7 @@ fn corpus_root() -> PathBuf { const KNOWN_UNSUPPORTED: &[&str] = &[ "cr3-", // Unverified CRX coding variants; exact supported cases live in cr3_corpus.rs. "orf-olympus-em", // Olympus compressed ORF + "-he.nef", // Nikon High Efficiency (HE / HE★) NEF, issue #193 "sraw", // Canon sRAW / mRAW ]; @@ -224,6 +225,24 @@ fn corpus_nef_compressed_matches_uncompressed() { /// raw.pixls.us has the same D7500 scene as 12- and 14-bit lossless compressed NEF. Both store black level 400 in /// maker note 0x003d (14-bit units): after subtracting the black level and scaling to white, the two must agree. +/// Issue #193: the Z f's High Efficiency NEF (no `0x0096` table, a JPEG XS codestream) is named as such and keeps a +/// full-size embedded JPEG to edit, while the same body's Lossless compressed NEF decodes. +#[test] +fn corpus_nef_high_efficiency_is_named() { + let dir = corpus_root().join("raw"); + let (Ok(he), Ok(lossless)) = (std::fs::read(dir.join("nef-nikon-zf-he.nef")), std::fs::read(dir.join("nef-nikon-zf-lossless.nef"))) else { + eprintln!("skip: Z f samples absent"); + return; + }; + for result in [decode(&he).map(|_| ()), probe_info(&he).map(|_| ())] { + let Err(RawError::Unsupported(why)) = result else { panic!("Z f HE: expected unsupported") }; + assert!(why.starts_with("Nikon High Efficiency NEF ("), "{why}"); + } + assert!(embedded_preview(&he).is_some_and(|p| p.len() > 1 << 20), "Z f HE: no full-size preview"); + let img = decode(&lossless).unwrap(); + assert_eq!((img.width, img.height, img.bits), (6064, 4040, 14)); +} + #[test] fn corpus_nef_12_bit_black_level_matches_14_bit() { let dir = corpus_root().join("raw"); @@ -328,17 +347,20 @@ fn corpus_proraw_sky_matte() { } /// Issue #148: Sony ARWs from before ~2017 carry no plain white-balance, black-level or crop tags in the raw IFD. -/// White balance comes from the maker note's enciphered `Tag2010`, the black level from the encrypted `SR2SubIFD` -/// and the crop from `FullImageSize`; without them the RX100 III opened bright green. Expected values: the black -/// levels agree with each sensor's dark-pixel floor, the gains with the neutral sky of the camera JPEG. +/// White balance and black level come from the encrypted `SR2SubIFD`, the crop from `FullImageSize`; without them +/// the RX100 III opened bright green. Expected values: the black levels agree with each sensor's dark-pixel floor; +/// the gains are the `SR2SubIFD`'s `WB_RGGBLevels`, within 4 % of the gains that make the camera JPEG's neutral +/// pixels neutral (RX100 III 2.59 / 1.69, RX100 2.21 / 2.01, ILCE-7RM2 2.67 / 1.47). Until issue #535 they came +/// from the maker note's `Tag2010` (2.61 / 1.72, 2.23 / 2.00, 2.58 / 1.46), which differs from what the camera +/// applied on other files (see `corpus_sony_sr2_white_balance`). #[test] fn corpus_sony_pre2017_colour_metadata() { let dir = corpus_root().join("raw"); // (file, black, approximate R and B gains, crop width × height) let cases = [ - ("arw-sony-rx100m3.arw", 800.0, [2.61, 1.72], (5472, 3648)), - ("arw-sony-rx100.arw", 800.0, [2.23, 2.00], (5472, 3648)), - ("arw-sony-a7rm2-12bit-uncompressed.arw", 512.0, [2.58, 1.46], (7952, 5304)), + ("arw-sony-rx100m3.arw", 800.0, [2.55, 1.72], (5472, 3648)), + ("arw-sony-rx100.arw", 800.0, [2.21, 1.99], (5472, 3648)), + ("arw-sony-a7rm2-12bit-uncompressed.arw", 512.0, [2.58, 1.45], (7952, 5304)), ]; let mut seen = 0; for (name, black, [r, b], (cw, ch)) in cases { @@ -357,6 +379,195 @@ fn corpus_sony_pre2017_colour_metadata() { eprintln!("pre-2017 Sony ARW colour metadata checked on {seen} files"); } +/// Sony ILCE-7CR (61 MP), three of the camera's raw codings of one scene on raw.pixls.us: lossless compressed L +/// (LJ92 tiles of 2×2 cells), lossless compressed M (subsampled YCbCr, already white-balanced) and compressed +/// (ARW2). Expected values: the files' own black level and default crop, and the as-shot gains of the camera's +/// colour-temperature setting. +#[test] +fn corpus_sony_a7cr_codings() { + let dir = corpus_root().join("raw"); + // (file, colour-filter layout, as-shot R and B gains, default crop x, y, width, height) + let cases = [ + ("arw-sony-a7cr-lossless-l.arw", Some("RGGB"), [2.625, 1.598], (32, 20, 9504, 6336)), + ("arw-sony-a7cr-compressed.arw", Some("RGGB"), [2.625, 1.598], (32, 20, 9504, 6336)), + ("arw-sony-a7cr-lossless-m.arw", None, [1.0, 1.0], (20, 12, 6240, 4160)), + ]; + let mut decoded = Vec::new(); + for (name, cfa, [r, b], crop) in cases { + 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"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(img.metadata.model.as_deref(), Some("ILCE-7CR"), "{name}: model"); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), cfa, "{name}: colour-filter layout"); + // Bayer codings use the file's black level; the YCbCr M coding's pedestal is handled separately (#535, item 3). + if cfa.is_some() { + assert_eq!(img.black.mean(), 512.0, "{name}: black level"); + } + assert!(img.white_at(0) > 15000.0, "{name}: white {} (14-bit scale)", img.white_at(0)); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance")); + assert!((wb[0] - r).abs() < 0.01 && wb[1] == 1.0 && (wb[2] - b).abs() < 0.01, "{name}: white balance {wb:?}"); + assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), crop, "{name}: default crop"); + decoded.push((name, img)); + } + // The lossless and compressed codings are two exposures of the same scene with the same framing: inside the + // default crop their mosaics must agree (up to the small differences between two exposures). + let crop_means = |img: &lightcraft_raw::RawImage| { + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let (w, c, b) = (img.width, img.crop, 32); + let mut out = Vec::new(); + for by in 0..c.height / b { + for bx in 0..c.width / b { + let (x0, y0) = ((c.x + bx * b) & !1, (c.y + by * b) & !1); + let (mut s, mut n) = (0f64, 0f64); + for y in y0..y0 + b { + for x in (x0..x0 + b).step_by(2) { + s += d[y * w + x + 1 - (y & 1)] as f64; + n += 1.0; + } + } + out.push(s / n); + } + } + out + }; + let bayer: Vec<_> = decoded.iter().filter(|(_, img)| img.cfa.is_some()).collect(); + if let [(a_name, a), (b_name, b)] = bayer.as_slice() { + let r = correlation(&crop_means(a), &crop_means(b)); + eprintln!("ILCE-7CR {a_name} vs {b_name}: green block correlation {r:.4}"); + assert!(r > 0.98, "correlation {r}"); + } + eprintln!("ILCE-7CR codings checked on {} files", decoded.len()); +} + +/// Issue #535: Sony black levels against the data. The SR2SubIFD keeps the black level at a position that depends +/// on its layout (the DSLR-A500 and A700 read 365 and 975 from a fixed position, where ExifTool and the data say +/// 512), and the downsized lossless M and S codings (linear YCbCr) sit 512 above the recorded level (1024, with +/// white moved by the same 512). Each file's darkest 0.1 % per channel must lie just above its black level: a black +/// level that is too low leaves a pedestal, one that is too high clips the shadows. +#[test] +fn corpus_sony_black_levels_sit_at_the_data_floor() { + use lightcraft_raw::RawData; + let dir = corpus_root().join("raw"); + // (file, black, white): SR2SubIFD black at three layouts, a plain 0x7310, and the lossless L / M / S codings + let cases = [ + ("arw-sony-a500.arw", 512.0, None), + ("arw-sony-a700.arw", 512.0, None), + ("arw-sony-rx100m3.arw", 800.0, None), + ("arw-sony-a7m4-lossless-l.arw", 512.0, Some(16383.0)), + ("arw-sony-a7m4-lossless-m.arw", 1024.0, Some(16895.0)), + ("arw-sony-a7m4-lossless-s.arw", 1024.0, Some(16895.0)), + ]; + let mut seen = 0; + for (name, black, white) in cases { + 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"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(img.black.mean(), black, "{name}: black level"); + if let Some(white) = white { + assert_eq!(img.white_at(0), white, "{name}: white level"); + } + let RawData::U16(ref data) = img.data else { panic!("{name}: integer samples expected") }; + // per CFA position (2×2) or per colour sample, inside the default crop, about a million samples each + let channels = if img.cpp == 3 { 3 } else { 4 }; + let (a, c) = (img.active_area, img.crop); + let step = (c.width * c.height / 1_000_000).max(1); + let mut samples = vec![Vec::new(); channels]; + for (k, (x, y)) in (c.y..c.y + c.height).flat_map(|y| (c.x..c.x + c.width).map(move |x| (x + a.x, y + a.y))).enumerate() { + if (k / 2) % step != 0 { + continue; + } + if img.cpp == 3 { + for (s, v) in samples.iter_mut().enumerate() { + v.push(data[(y * img.width + x) * 3 + s]); + } + } else { + samples[(y % 2) * 2 + x % 2].push(data[y * img.width + x]); + } + } + for (i, mut v) in samples.into_iter().enumerate() { + v.sort_unstable(); + let floor = f32::from(v[v.len() / 1000]); + assert!((black - 64.0..=black + 128.0).contains(&floor), "{name}: channel {i}: darkest 0.1 % at {floor}, black level {black}"); + } + seen += 1; + } + eprintln!("Sony black levels checked against the data floor on {seen} files"); +} + +/// Issue #535: the white balance a Sony ARW records as applied, `WB_RGGBLevels`, read from the encrypted +/// `SR2SubIFD` when the raw IFD has no plain copy. Before, the first three opened with no white balance (green) and +/// the last two with the maker note's `Tag2010` gains, which there follow the scene rather than the preset used. +/// Expected levels: ExifTool's decrypted `WB_RGGBLevels` (green 1024). In brackets, the gains that make the +/// camera JPEG's neutral pixels neutral. +#[test] +fn corpus_sony_sr2_white_balance() { + let dir = corpus_root().join("raw"); + // (file, R and B levels) + let cases = [ + ("arw-sony-a500.arw", [2212, 1416]), // 27152-byte SR2SubIFD layout (2.04 / 1.35) + ("arw-sony-a33.arw", [2344, 1508]), // 29000-byte layout (2.29 / 1.48) + ("arw-sony-a700.arw", [2128, 1564]), // 62112-byte layout (too few neutral pixels) + ("arw-sony-a3500-5600k.arw", [2932, 1576]), // 5600 K (2.76 / 1.53; Tag2010 1.89 / 3.08) + ("arw-sony-a7s-shade.arw", [2932, 1372]), // Shade (2.90 / 1.32; Tag2010 2.63 / 1.52) + ]; + let mut seen = 0; + for (name, [r, b]) in cases { + let Ok(bytes) = std::fs::read(dir.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot white balance")); + let (er, eb) = (r as f32 / 1024.0, b as f32 / 1024.0); + assert!((wb[0] - er).abs() < 1e-4 && wb[1] == 1.0 && (wb[2] - eb).abs() < 1e-4, "{name}: white balance {wb:?}, expected [{er}, 1, {eb}]"); + seen += 1; + } + eprintln!("Sony SR2SubIFD white balance checked on {seen} files"); +} + +/// Issue #535: pre-2017 Sony bodies without crop tags shot in the camera's 16:9 mode. The ILCE-7SM2 records the +/// 16:9 size in `FullImageSize`, the DSLR-A580 only in the Exif image size; both crops are centred vertically (each +/// camera JPEG registered on its raw) and drop the right-edge padding, so the image matches its JPEG's aspect ratio +/// and gets a camera look. +#[test] +fn corpus_sony_16x9_crops() { + let dir = corpus_root().join("raw"); + let mut seen = 0; + for (name, (x, y, w, h)) in [("arw-sony-a7sm2-16x9.arw", (0, 232, 4240, 2384)), ("arw-sony-a580-16x9.arw", (0, 260, 4912, 2760))] { + 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"); + continue; + } + Err(e) => panic!("{name}: {e}"), + }; + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let c = img.crop; + assert_eq!((c.x, c.y, c.width, c.height), (x, y, w, h), "{name}: crop"); + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no preview")); + let mut d = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&jpeg)); + d.decode_headers().unwrap(); + let info = d.info().unwrap(); + let (aspect, preview) = (c.width as f64 / c.height as f64, info.width as f64 / info.height as f64); + assert!((aspect / preview - 1.0).abs() < 0.02, "{name}: crop aspect {aspect} vs camera JPEG {preview}"); + seen += 1; + } + eprintln!("Sony 16:9 crops checked on {seen} files"); +} + /// The embedded JPEG as linear RGB, reduced to `gw × gh` cells. fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> { use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; diff --git a/crates/scenes/src/lib.rs b/crates/scenes/src/lib.rs index 0b7ad0163..1004b2ea9 100644 --- a/crates/scenes/src/lib.rs +++ b/crates/scenes/src/lib.rs @@ -70,9 +70,20 @@ impl Scene { /// Render fitting within `max_edge` on the long side. pub fn render_fit(&self, max_edge: usize) -> Rgb32f { + let (w, h) = self.fit(max_edge); + self.render(w, h) + } + + /// [`Scene::render_fit`] on the calling thread alone, without rayon (the same pixels). + pub fn render_fit_serial(&self, max_edge: usize) -> Rgb32f { + let (w, h) = self.fit(max_edge); + paint::render_serial(self.kind, self.seed, w, h) + } + + fn fit(&self, max_edge: usize) -> (usize, usize) { let a = self.aspect(); let (w, h) = if a >= 1.0 { (max_edge, (max_edge as f32 / a).round() as usize) } else { ((max_edge as f32 * a).round() as usize, max_edge) }; - self.render(w.max(1), h.max(1)) + (w.max(1), h.max(1)) } } @@ -203,6 +214,19 @@ mod tests { assert!(max > 2.0, "{max}"); } + /// The serial render is the parallel one, pixel for pixel (both with and without supersampling), and + /// an empty size is an empty image. + #[test] + fn the_serial_render_is_the_same_picture() { + for s in demo_library().iter().take(3) { + for edge in [40, 700] { + assert_eq!(s.render_fit_serial(edge), s.render_fit(edge), "{} at {edge}", s.name); + } + } + assert!(paint::render_serial(Kind::Dunes, 1, 0, 5).data.is_empty()); + assert!(demo_library()[0].render(0, 0).data.is_empty()); + } + #[test] fn resolution_independent() { let s = &demo_library()[1]; diff --git a/crates/scenes/src/paint.rs b/crates/scenes/src/paint.rs index 5cfd61e3a..4f3c4560b 100644 --- a/crates/scenes/src/paint.rs +++ b/crates/scenes/src/paint.rs @@ -197,12 +197,25 @@ fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> } pub fn render(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { + render_rows(kind, seed, w, h, true) +} + +/// [`render`] on the calling thread alone, without rayon: the same pixels, for a caller that must +/// not run other queued work while it waits for rows (see `lightcraft_engine::demo`). +pub fn render_serial(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { + render_rows(kind, seed, w, h, false) +} + +fn render_rows(kind: Kind, seed: u32, w: usize, h: usize, parallel: bool) -> Rgb32f { + let mut img = Rgb32f::new(w, h); + if w == 0 || h == 0 { + return img; + } let aspect = w as f32 / h as f32; let p = P { seed, aspect }; let ss = if w * h <= 300_000 { 2 } else { 1 }; let to2020 = SRGB.to_space(&REC2020).to_f32(); - let mut img = Rgb32f::new(w, h); - img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let paint = |(y, row): (usize, &mut [[f32; 3]])| { for (xi, px) in row.iter_mut().enumerate() { let mut acc = [0.0f32; 3]; for sy in 0..ss { @@ -220,7 +233,12 @@ pub fn render(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f { (m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]).max(0.0), ]; } - }); + }; + if parallel { + img.data.par_chunks_mut(w).enumerate().for_each(paint); + } else { + img.data.chunks_mut(w).enumerate().for_each(paint); + } img } diff --git a/crates/tiff/src/tags.rs b/crates/tiff/src/tags.rs index 5771316a5..458aefe53 100644 --- a/crates/tiff/src/tags.rs +++ b/crates/tiff/src/tags.rs @@ -173,6 +173,11 @@ pub const OPCODE_LIST_3: u16 = 51022; pub const NOISE_PROFILE: u16 = 51041; pub const DEFAULT_USER_CROP: u16 = 51125; pub const PROFILE_GAIN_TABLE_MAP: u16 = 52525; +/// DNG 1.7 row interleave: the stored raster holds this many interleaved row groups (each group is +/// every `n`-th row of the image), so it must be un-interleaved before the data is a mosaic. +pub const ROW_INTERLEAVE_FACTOR: u16 = 50975; +/// DNG 1.7 column interleave, the column-wise counterpart of [`ROW_INTERLEAVE_FACTOR`]. +pub const COLUMN_INTERLEAVE_FACTOR: u16 = 52547; pub const CALIBRATION_ILLUMINANT_3: u16 = 52529; pub const CAMERA_CALIBRATION_3: u16 = 52530; pub const COLOR_MATRIX_3: u16 = 52531; @@ -200,6 +205,8 @@ pub mod compression { /// DNG lossy JPEG (baseline DCT, 8-bit). pub const LOSSY_JPEG: u16 = 34892; pub const JPEG_XL: u16 = 52546; + /// SMPTE ST 2073 (VC-5) as written by GoPro's GPR files (the value measured in them). + pub const VC5: u16 = 9; /// Nikon NEF Huffman compression. pub const NIKON: u16 = 34713; /// Sony ARW compressed (the value used by ARW files). diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 126723949..ea00ae3cc 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -206,5 +206,20 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "In „{parent}“ einfügen", + "Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.", + "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?", + "There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?", + "Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt", + "On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos", + "Remove “{name}”": "„{name}“ entfernen", + "There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“", + "Added {n} keyword{}": "Stichwörter hinzugefügt: {n}{:.0}", + "; {n} gained synonyms": "; mit neuen Synonymen: {n}", + "Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", + " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c24b59cc0..c4bded93e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1889,5 +1889,132 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Decrease Rating": "Bewertung verringern", + "Increase Rating": "Bewertung erhöhen", + "Click to type a value": "Klicken, um einen Wert einzugeben", + "Angle": "Winkel", + "Video": "Video", + "Copyrighted": "Urheberrechtlich geschützt", + "Public Domain": "Gemeinfrei", + "Unknown": "Unbekannt", + "Landscape (wide)": "Querformat", + "Portrait (tall)": "Hochformat", + "Square": "Quadratisch", + "In Color": "Farbig", + "No Label": "Ohne Markierung", + "Choose an album…": "Album wählen…", + "No albums yet": "Noch keine Alben", + "Fix the marked rules to save this album.": "Korrigiere die markierten Regeln, um dieses Album zu speichern.", + "Some rules need fixing (Edit Smart Album…):": "Einige Regeln müssen korrigiert werden (Smart-Album bearbeiten…):", + "isn't a field rules know": "ist kein bekanntes Feld", + "can't be tested that way": "lässt sich so nicht prüfen", + "is yes or no": "ist Ja oder Nein", + "needs a number": "braucht eine Zahl", + "needs a time like 1/250 or 2": "braucht eine Zeit wie 1/250 oder 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "braucht ein Datum wie 2026, 2026-08 oder 2026-08-14", + "has the first date after the second": "das erste Datum liegt nach dem zweiten", + "needs two values": "braucht zwei Werte", + "needs a number more than 0": "braucht eine Zahl größer als 0", + "needs hours, days, weeks, months or years": "braucht Stunden, Tage, Wochen, Monate oder Jahre", + "isn't one of the choices": "ist keine der Auswahlmöglichkeiten", + "needs something to look for (or use “is empty”)": "braucht einen Suchbegriff (oder „ist leer“)", + "needs something to look for (or use “isn't empty”)": "braucht einen Suchbegriff (oder „ist nicht leer“)", + "needs text": "braucht Text", + "can't match any rating from 0 to 5": "passt zu keiner Bewertung von 0 bis 5", + "needs an album": "braucht ein Album", + "names an album that no longer exists": "nennt ein Album, das es nicht mehr gibt", + "can't test a folder; choose an album in it": "kann keinen Ordner prüfen; wähle ein Album darin", + "This group is empty: add a rule or remove it.": "Diese Gruppe ist leer: Füge eine Regel hinzu oder entferne sie.", + "needs something to look for (or use “are empty”)": "braucht einen Suchbegriff (oder „sind leer“)", + "needs something to look for (or use “aren't empty”)": "braucht einen Suchbegriff (oder „sind nicht leer“)", + "Mo": "Mo", + "Tu": "Di", + "We": "Mi", + "Th": "Do", + "Fr": "Fr", + "Sa": "Sa", + "Su": "So", + "Pick a date": "Datum wählen", + "would make this album include itself through other smart albums": "würde dieses Album über andere Smart-Alben sich selbst enthalten lassen", + "Search albums": "Alben suchen", + "No albums match": "Kein Album passt", + "This is the album you're editing": "Das ist das Album, das du bearbeitest", + "It tests this album, so testing it back would loop": "Es prüft dieses Album; es zurückzuprüfen wäre ein Kreis", + "Copy Name": "Kopiename", + "In a Stack": "In einem Stapel", + "File Extension": "Dateiendung", + "Video Duration": "Videodauer", + "Keyword Count": "Anzahl Stichwörter", + "People Count": "Anzahl Personen", + "Shutter Speed": "Belichtungszeit", + "Cropped": "Zugeschnitten", + "Keywords & People": "Stichwörter & Personen", + "Description": "Beschreibung", + "Camera Info": "Kamera-Info", + "Develop": "Bearbeitung", + "Keyword List": "Stichwortliste", + "Filter Keywords": "Stichwörter filtern", + "No keywords yet": "Noch keine Stichwörter", + "No keywords match": "Keine passenden Stichwörter", + "Keyword Name": "Stichwortname", + "Synonyms": "Synonyme", + "Separated by commas": "Durch Kommas getrennt", + "Keyword Tag Options": "Stichwort-Optionen", + "Include on Export": "Beim Export einschließen", + "Export Containing Keywords": "Übergeordnete Stichwörter exportieren", + "Export Synonyms": "Synonyme exportieren", + "Create Keyword Tag": "Stichwort erstellen", + "Edit Keyword Tag": "Stichwort bearbeiten", + "Create": "Erstellen", + "No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht.", + "Edit Keyword Tag…": "Stichwort bearbeiten…", + "Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen", + "Purge Unused Keywords": "Nicht verwendete Stichwörter löschen", + "Delete Keyword…": "Stichwort löschen…", + "Drop here for the top level": "Hier ablegen für die oberste Ebene", + "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.", + "Select photos to give them keywords.": "Wählen Sie Fotos aus, um ihnen Stichwörter zu geben.", + "Add to All Selected Photos": "Allen ausgewählten Fotos hinzufügen", + "Will Export": "Wird exportiert", + "No keywords are exported": "Es werden keine Stichwörter exportiert", + "& Containing": "& Übergeordnete", + "Edit Set…": "Satz bearbeiten…", + "Edit Keyword Set": "Stichwortsatz bearbeiten", + "Keywords (⌥1–⌥9)": "Stichwörter (⌥1–⌥9)", + "A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen", + "Save as a new set": "Als neuen Satz speichern", + "Recent Keywords is built in: choose another name": "„Letzte Stichwörter“ ist vorgegeben: Wählen Sie einen anderen Namen", + "Import Keywords…": "Stichwörter importieren…", + "Export Keywords…": "Stichwörter exportieren…", + "Import Keywords": "Stichwörter importieren", + "Export Keywords": "Stichwörter exportieren", + "Keyword Lists": "Stichwortlisten", + "Synchronize Folder…": "Ordner synchronisieren…", + "Synchronize Folder": "Ordner synchronisieren", + "Synchronize": "Synchronisieren", + "Scanning…": "Wird durchsucht…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Filialdateien", + "Bring the library up to date with this folder and the folders inside it:": "Die Bibliothek mit diesem Ordner und den Ordnern darin abgleichen:", + "Import new photos": "Neue Fotos importieren", + "Remove missing photos from the library": "Fehlende Fotos aus der Bibliothek entfernen", + "Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Filialdateien lesen", + "The library is up to date with this folder.": "Die Bibliothek ist mit diesem Ordner auf dem neuesten Stand.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", + "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", + "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.", + "Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde", + "A folder is already being synchronized": "Es wird bereits ein Ordner synchronisiert", + "Synchronizing…": "Wird synchronisiert…", + "Relinked": "Neu verknüpft", + "Removed": "Entfernt", + "Metadata read": "Metadaten gelesen", + "Failed": "Fehlgeschlagen", + "Stopped": "Angehalten", + "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", + "Not saved": "Nicht gespeichert", + "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", + "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 54418c768..ce6ace8ce 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -206,5 +206,20 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Poner dentro de “{parent}”", + "Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.", + "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?", + "There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?", + "Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}", + "On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas", + "Remove “{name}”": "Quitar “{name}”", + "There is a keyword set “{name}” already": "Ya hay un conjunto de palabras clave “{name}”", + "Added {n} keyword{}": "Palabras clave añadidas: {n}{:.0}", + "; {n} gained synonyms": "; con sinónimos nuevos: {n}", + "Exported {n} keyword{}": "Palabras clave exportadas: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One no importa una lista con ; , < o > en una palabra clave: {names}", + " — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 387ac9717..eb13321af 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1734,5 +1734,132 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Decrease Rating": "Reducir valoración", + "Increase Rating": "Aumentar valoración", + "Click to type a value": "Hacer clic para escribir un valor", + "Angle": "Ángulo", + "Video": "Vídeo", + "Copyrighted": "Con copyright", + "Public Domain": "Dominio público", + "Unknown": "Desconocido", + "Landscape (wide)": "Horizontal (apaisada)", + "Portrait (tall)": "Vertical (retrato)", + "Square": "Cuadrada", + "In Color": "En color", + "No Label": "Sin etiqueta", + "Choose an album…": "Elige un álbum…", + "No albums yet": "Aún no hay álbumes", + "Fix the marked rules to save this album.": "Corrige las reglas marcadas para guardar este álbum.", + "Some rules need fixing (Edit Smart Album…):": "Hay reglas que corregir (Editar álbum inteligente…):", + "isn't a field rules know": "no es un campo conocido", + "can't be tested that way": "no se puede comprobar así", + "is yes or no": "es Sí o No", + "needs a number": "necesita un número", + "needs a time like 1/250 or 2": "necesita un tiempo como 1/250 o 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "necesita una fecha como 2026, 2026-08 o 2026-08-14", + "has the first date after the second": "la primera fecha es posterior a la segunda", + "needs two values": "necesita dos valores", + "needs a number more than 0": "necesita un número mayor que 0", + "needs hours, days, weeks, months or years": "necesita horas, días, semanas, meses o años", + "isn't one of the choices": "no es ninguna de las opciones", + "needs something to look for (or use “is empty”)": "necesita algo que buscar (o usa «está vacío»)", + "needs something to look for (or use “isn't empty”)": "necesita algo que buscar (o usa «no está vacío»)", + "needs text": "necesita texto", + "can't match any rating from 0 to 5": "no coincide con ninguna valoración de 0 a 5", + "needs an album": "necesita un álbum", + "names an album that no longer exists": "indica un álbum que ya no existe", + "can't test a folder; choose an album in it": "no puede comprobar una carpeta; elige un álbum dentro", + "This group is empty: add a rule or remove it.": "Este grupo está vacío: añade una regla o quítalo.", + "needs something to look for (or use “are empty”)": "necesita algo que buscar (o usa «están vacíos»)", + "needs something to look for (or use “aren't empty”)": "necesita algo que buscar (o usa «no están vacíos»)", + "Mo": "Lu", + "Tu": "Ma", + "We": "Mi", + "Th": "Ju", + "Fr": "Vi", + "Sa": "Sá", + "Su": "Do", + "Pick a date": "Elegir una fecha", + "would make this album include itself through other smart albums": "haría que este álbum se incluyera a sí mismo a través de otros álbumes inteligentes", + "Search albums": "Buscar álbumes", + "No albums match": "Ningún álbum coincide", + "This is the album you're editing": "Es el álbum que estás editando", + "It tests this album, so testing it back would loop": "Comprueba este álbum, así que comprobarlo a su vez crearía un bucle", + "Copy Name": "Nombre de la copia", + "In a Stack": "En una pila", + "File Extension": "Extensión del archivo", + "Video Duration": "Duración del vídeo", + "Keyword Count": "Número de palabras clave", + "People Count": "Número de personas", + "Shutter Speed": "Velocidad de obturación", + "Cropped": "Recortada", + "Keywords & People": "Palabras clave y personas", + "Description": "Descripción", + "Camera Info": "Información de la cámara", + "Develop": "Edición", + "Keyword List": "Lista de palabras clave", + "Filter Keywords": "Filtrar palabras clave", + "No keywords yet": "Aún no hay palabras clave", + "No keywords match": "Ninguna palabra clave coincide", + "Keyword Name": "Nombre de la palabra clave", + "Synonyms": "Sinónimos", + "Separated by commas": "Separados por comas", + "Keyword Tag Options": "Opciones de palabra clave", + "Include on Export": "Incluir al exportar", + "Export Containing Keywords": "Exportar palabras clave contenedoras", + "Export Synonyms": "Exportar sinónimos", + "Create Keyword Tag": "Crear palabra clave", + "Edit Keyword Tag": "Editar palabra clave", + "Create": "Crear", + "No photo has it; the keywords below it go too.": "Ninguna foto la tiene; las palabras clave que contiene también se eliminan.", + "Edit Keyword Tag…": "Editar palabra clave…", + "Put New Keywords Inside This Keyword": "Poner las nuevas palabras clave dentro de esta", + "Purge Unused Keywords": "Eliminar palabras clave no usadas", + "Delete Keyword…": "Eliminar palabra clave…", + "Drop here for the top level": "Soltar aquí para el nivel superior", + "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave.", + "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave.", + "Add to All Selected Photos": "Añadir a todas las fotos seleccionadas", + "Will Export": "Se exportará", + "No keywords are exported": "No se exporta ninguna palabra clave", + "& Containing": "y contenedoras", + "Edit Set…": "Editar conjunto…", + "Edit Keyword Set": "Editar conjunto de palabras clave", + "Keywords (⌥1–⌥9)": "Palabras clave (⌥1–⌥9)", + "A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre", + "Save as a new set": "Guardar como conjunto nuevo", + "Recent Keywords is built in: choose another name": "“Palabras clave recientes” es fijo: elige otro nombre", + "Import Keywords…": "Importar palabras clave…", + "Export Keywords…": "Exportar palabras clave…", + "Import Keywords": "Importar palabras clave", + "Export Keywords": "Exportar palabras clave", + "Keyword Lists": "Listas de palabras clave", + "Synchronize Folder…": "Sincronizar carpeta…", + "Synchronize Folder": "Sincronizar carpeta", + "Synchronize": "Sincronizar", + "Scanning…": "Explorando…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Buscar fotos añadidas a esta carpeta del disco o que faltan en ella, y archivos XMP complementarios cambiados por otras apps", + "Bring the library up to date with this folder and the folders inside it:": "Poner la biblioteca al día con esta carpeta y las carpetas que contiene:", + "Import new photos": "Importar fotos nuevas", + "Remove missing photos from the library": "Quitar de la biblioteca las fotos que faltan", + "Read metadata updates from XMP sidecars": "Leer cambios de metadatos de los archivos XMP complementarios", + "The library is up to date with this folder.": "La biblioteca está al día con esta carpeta.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Las fotos que faltan pasan a Eliminadas recientemente y se pueden restaurar; no se toca ningún archivo del disco.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leer un archivo complementario sustituye la valoración, las etiquetas, los metadatos y las ediciones de la foto por lo que contiene.", + "Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar", + "Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado.", + "Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió", + "A folder is already being synchronized": "Ya se está sincronizando una carpeta", + "Synchronizing…": "Sincronizando…", + "Relinked": "Revinculadas", + "Removed": "Quitadas", + "Metadata read": "Metadatos leídos", + "Failed": "Con errores", + "Stopped": "Detenido", + "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", + "Not saved": "Sin guardar", + "A folder is being synchronized": "Se está sincronizando una carpeta", + "Wait for the import to finish": "Espera a que termine la importación" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json new file mode 100644 index 000000000..6e6093292 --- /dev/null +++ b/crates/ui-egui/locales/fr-formats.json @@ -0,0 +1,225 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} avec plusieurs fichiers similaires : utilisez Localiser)", + " ({} in Recently Deleted)": " ({} dans « Supprimés récemment »)", + " · {:.0}% clipped": " · {:.0}% écrêté", + " · {failed} couldn't be rendered": " · {failed} impossible(s) à afficher", + " · {skipped} skipped (file exists)": " · {skipped} ignoré(s) (le fichier existe)", + " · {} duplicate{} skipped": " · {} doublon{} ignoré(s)", + " · {} failed: {}": " · {} échec(s) : {}", + " · {} moved": " · {} déplacé(s)", + " · {} not readable": " · {} illisible(s)", + " · {} original{} left at the source": " · {} original/originaux{} conservé(s) à la source", + " · {} restored from Recently Deleted": " · {} restauré(s) depuis « Supprimés récemment »", + " — not carried over: {}{}": " — non transféré : {}{}", + "+{} more": "+{} de plus", + ", {failed} file{} not readable": ", {failed} fichier{} illisible(s)", + "; {kept} already had a location": " ; {kept} possédaient déjà un emplacement", + "; {outside} outside its time range": " ; {outside} hors de leur plage horaire", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{} : {} PR, {} commits, +{} / −{} lignes (Δ {}), +{} / −{} ressources binaires, {} – {}", + "Add {local_n} to My Photos": "Ajouter {local_n} à Mes photos", + "Add {} Photo{}": "Ajouter {} photo{}", + "Add {} Photo{}, restore {}": "Ajouter {} photo{}, restaurer {}", + "Add “{}” to Favorites": "Ajouter « {} » aux favoris", + "Added {n} photo{} to My Photos": "{n} photo{} ajoutée(s) à Mes photos", + "Added {n} photo{} to “{}”": "{n} photo{} ajoutée(s) à « {} »", + "Added {what} to {name}": "{what} ajouté à {name}", + "Adding photos… {} of {}": "Ajout des photos… {} sur {}", + "All {n} selected photos move by the same amount.": "Les {n} photos sélectionnées se déplacent de la même valeur.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Déjà dans la bibliothèque, dans « Supprimés récemment » : {} photo{} sélectionnée(s) · clic droit → Restaurer, ou Supprimer définitivement pour importer à nouveau", + "Already in the library: {} photo{} selected in All Photos": "Déjà dans la bibliothèque : {} photo{} sélectionnée(s) dans Toutes les photos", + "Auto ({})": "Automatique ({})", + "Auto Import: added {} photo{}": "Importation automatique : {} photo{} ajoutée(s)", + "Auto Sync: edits apply to {n} photos": "Synchronisation automatique : les modifications s’appliquent à {n} photos", + "Automatic ({} MB)": "Automatique ({} Mo)", + "Build 1:1 Previews ({scope})": "Créer les aperçus 1:1 ({scope})", + "Build Smart Previews ({scope})": "Créer les aperçus dynamiques ({scope})", + "Build Standard-Sized Previews ({scope})": "Créer les aperçus de taille standard ({scope})", + "Building previews for {} photos…": "Création des aperçus pour {} photos…", + "Click {}:1": "Cliquer sur {}:1", + "Couldn't open the editor: {e}": "Impossible d’ouvrir l’éditeur : {e}", + "Create {n} Virtual Copies": "Créer {n} copies virtuelles", + "Creates {} stacks from {} of {scope}": "Crée {} piles à partir de {} parmi {scope}", + "Decoder: {reason}": "Décodeur : {reason}", + "Delete \"{label}\"": "Supprimer « {label} »", + "Delete \"{label}\" (and the mask)": "Supprimer « {label} » (et le masque)", + "Delete {n} Photos": "Supprimer {n} photos", + "Delete “{current}”": "Supprimer « {current} »", + "Discard Smart Previews ({scope})": "Supprimer les aperçus dynamiques ({scope})", + "Done ({what}): {done} photo{plural}": "Terminé ({what}) : {done} photo{plural}", + "Duplicate \"{label}\"": "Dupliquer « {label} »", + "Example: {example}": "Exemple : {example}", + "Exported {n} photo{}": "{n} photo{} exportée(s)", + "Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}", + "Exported {} point curve preset{}": "{} préréglage{} de courbe de points exporté(s)", + "Exported {} preset{}": "{} préréglage{} exporté(s)", + "Exporting {} of {total}": "Exportation : {} sur {total}", + "Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)", + "Focus {:.0}": "Mise au point {:.0}", + "Folder “{}” (and its subfolders)": "Dossier « {} » (et ses sous-dossiers)", + "Found {n} missing photo{}; {left} still missing{unsure}": "{n} photo{} manquante(s) retrouvée(s) ; {left} toujours manquante(s){unsure}", + "Healed {n} dust spot{}": "{n} tache{} de poussière corrigée(s)", + "Import cancelled · {} photo{} added": "Importation annulée · {} photo{} ajoutée(s)", + "Import failed: {e}": "Échec de l’importation : {e}", + "Imported {n} point curve preset{}": "{n} préréglage{} de courbe de points importé(s)", + "Imported {n} preset{}": "{n} préréglage{} importé(s)", + "Imported {n} preset{}, {f} file{} not readable": "{n} préréglage{} importé(s), {f} fichier{} illisible(s)", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "{profiles} profil{} importé(s) (Navigateur de profils → leurs groupes)", + "Imported {} photo{}": "{} photo{} importée(s)", + "Insert at the cursor (also written {})": "Insérer à la position du curseur (également écrit {})", + "Label Set: {name}": "Ensemble d’étiquettes : {name}", + "LightCraft could not open its window: {e}": "LightCraft n’a pas pu ouvrir sa fenêtre : {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft n’a pas pu ouvrir sa fenêtre : {e}\n\nConsultez le fichier journal pour plus de détails : {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catalogue Lightroom analysé : {photos} photos, {collections} collections, {missing} manquante(s)", + "Lightroom import complete: {imported} photos added": "Importation Lightroom terminée : {imported} photos ajoutées", + "Lightroom import complete: {imported} photos added, {failed} failed": "Importation Lightroom terminée : {imported} photos ajoutées, {failed} échec(s)", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Importation Lightroom terminée : {imported} photos ajoutées ; avertissement d’index : {warning}", + "Lightroom import failed: {e}{warning}": "Échec de l’importation Lightroom : {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Échec de l’analyse Lightroom : {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT ou Apache-2.0. Polices : {} (OFL). Icônes : originales.", + "Matches: {}": "Correspondances : {}", + "Merging… {stage} {:.0}%": "Fusion… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Déplacer {what} vers « Supprimés récemment » ?", + "Named ({named_n})": "Nommés ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Application de « {k} » : cliquez sur les photos pour l’ajouter ou le retirer · Échap pour arrêter", + "Paste into {n} photo{} — only settings that were copied are pasted": "Coller dans {n} photo{} — seuls les réglages copiés seront collés", + "Preset: {name}": "Préréglage : {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Aperçu uniquement — modification du JPEG intégré de l’appareil photo ({})", + "Preview only — {}": "Aperçu uniquement — {}", + "Previews ready for {done} photo{plural}": "Aperçus prêts pour {done} photo{plural}", + "Profile: {}": "Profil : {}", + "Proof Copy ({})": "Copie d’épreuve ({})", + "Proof Preview · {}": "Aperçu d’épreuve · {}", + "Quick Develop · {n} photos": "Développement rapide · {n} photos", + "Quick Develop: {name}": "Développement rapide : {name}", + "Rated {}": "Noté {}", + "Reading Lightroom catalog… {done} of {total}": "Lecture du catalogue Lightroom… {done} sur {total}", + "Reading photos… {done} of {total}": "Lecture des photos… {done} sur {total}", + "Reading photos… {} of {}": "Lecture des photos… {} sur {}", + "Redo {}": "Rétablir {}", + "Remove “{name}” and its {count} photo{} from the library?": "Retirer « {name} » et ses {count} photo{} de la bibliothèque ?", + "Remove “{}” from Favorites": "Retirer « {} » des favoris", + "Removed from {}": "Retiré de {}", + "Removed {what} from {name}": "{what} retiré de {name}", + "Rename {n} Photos…": "Renommer {n} photos…", + "Rename “{name}”": "Renommer « {name} »", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Renomme « {from} » sur {n} photo{} (ainsi que les mots-clés qui en dépendent). Utilisez | pour séparer les niveaux, par ex. Voyages|Italie. Si le nom existe déjà, les deux sont fusionnés.", + "Rendering on the CPU: {why}": "Rendu sur le processeur : {why}", + "Replace {} with:": "Remplacer {} par :", + "Reset {name}": "Réinitialiser {name}", + "Reset {} Channel": "Réinitialiser le canal {}", + "Restore {}": "Restaurer {}", + "Restore {} Photo{}": "Restaurer {} photo{}", + "Saved export preset “{}”": "Préréglage d’exportation « {} » enregistré", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Évalue la mise au point et l’exposition de {scope}, puis repère les prises similaires réalisées à quelques secondes d’intervalle (rafales).", + "Show {hidden} hidden location{}": "Afficher {hidden} emplacement{} masqué(s)", + "Showing {n} of {}": "Affichage de {n} sur {}", + "Smart album: {}": "Album dynamique : {}", + "Tagged {n} photo{} from the tracklog": "{n} photo{} géolocalisée(s) à partir du journal de trajet", + "The SAM 3 download failed: {e}": "Échec du téléchargement de SAM 3 : {e}", + "Undo {}": "Annuler {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Les étiquettes inconnues{s} {} restent{} telles quelles (voir Étiquettes)", + "Updated “{name}”": "« {name} » mis à jour", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Version {} — bibliothèque photo et développeur RAW en Rust pur, développés indépendamment.", + "Version: {}": "Version : {}", + "Virtual copy “{name}” of {of}": "Copie virtuelle « {name} » de {of}", + "Working on {what} for {} photos…": "Traitement de {what} pour {} photos…", + "none (CPU rendering): {why}": "aucun (rendu sur le processeur) : {why}", + "the {n} selected photos": "les {n} photos sélectionnées", + "the {} photos in view": "les {} photos affichées", + "these {count} photos": "ces {count} photos", + "{count} smart preview{} · {:.1} MB": "{count} aperçu{0} dynamique{0} · {1:.1} Mo", + "{f} folders (and their subfolders)": "{f} dossier(s) (et leurs sous-dossiers)", + "{label} Grading": "Étalonnage de {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} sur chaque photo sélectionnée", + "{label} {index} of {n}": "{label} {index} sur {n}", + "{label}, {n} photos": "{label}, {n} photos", + "{matching} of {t} photos": "{matching} sur {t} photos", + "{matching} photos": "{matching} photos", + "{mode} spot {} of {}": "Tache {mode} {} sur {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} échec(s))", + "{name} Label": "Étiquette {name}", + "{name} Off": "{name} : désactivé", + "{name} On": "{name} : activé", + "{name} opened for editing; it is stacked with the original": "{name} ouvert pour modification ; il est empilé avec l’original", + "{name} — double-click to reset it": "{name} — double-cliquez pour réinitialiser", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Impossible d’écrire {n} modification{} sur le disque : {e}\nSi vous quittez maintenant, {} seront perdues.", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "{n} modification{} enregistrée(s) en mémoire mais non écrite(s) sur le disque : {e}\nLightCraft réessaiera automatiquement ; quitter maintenant entraînerait la perte de {}.", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "{n} modification{} enregistrée(s) en mémoire mais non écrite(s) sur le disque : {e} — LightCraft réessaiera.", + "{n} found · {sel} selected": "{n} trouvé(s) · {sel} sélectionné(s)", + "{n} photos": "{n} photos", + "{n} photos in {} are hidden by the filters above": "Les filtres ci-dessus masquent {n} photos dans {}", + "{n} photo{}": "{n} photo{}", + "{n} photo{} match · updates automatically as photos change": "{n} photo{} correspondante(s) · mise à jour automatique lorsque les photos changent", + "{n} photo{} now · updates automatically as photos change": "{n} photo{} actuellement · mise à jour automatique lorsque les photos changent", + "{pid} (missing)": "{pid} (manquant)", + "{sel_n} selected · {counted}": "{sel_n} sélectionné(s) · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} photo{}", + "{weekday}, {day}": "{weekday} {day}", + "{weekday}, {day} {month} {year}": "{weekday} {day} {month} {year}", + "{what} merge added": "Fusion de {what} ajoutée", + "{what} merge failed: {e}": "Échec de la fusion de {what} : {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Directives {{date:…}} : {}", + "{} and higher": "{} et plus", + "{} contributors · {} commits": "{} contributeurs · {} commits", + "{} failed": "{} échec(s)", + "{} files": "{} fichiers", + "{} files and {f} folder{}": "{} fichiers et {f} dossier{}", + "{} item{}": "{} élément{}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} guides sur 4 — faites-les glisser le long des lignes qui doivent être verticales ou horizontales.", + "{} photos": "{} photos", + "{} photos · exposures {} EV": "{} photos · expositions {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "{} photo{} illisible(s)", + "{} sample{}": "{} échantillon{}", + "{} sec": "{} s", + "{} spot(s) on this photo": "{} tache(s) sur cette photo", + "{} — show their photos": "{} — afficher leurs photos", + "· {dups} already in the library": "· déjà dans la bibliothèque : {dups}", + "· {n} photo{}": "· {n} photo{}", + "… and {more} more": "… et {more} de plus", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ne peut pas encore décoder cette variante RAW ({}). Vous modifiez l’aperçu JPEG intégré de l’appareil photo, qui inclut déjà le style d’image (par ex. monochrome) et la balance des blancs de l’appareil.", + "Filtered: {label}": "Filtré : {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "{n} photo{} similaire(s) · Affichage → Effacer les filtres pour tout voir", + "Moved; {} photo(s) relinked": "Déplacé ; {} photo(s) de nouveau associée(s)", + "{} label (click more labels to show any of them)": "Étiquette {} (cliquez sur « autres étiquettes » pour afficher celles de votre choix)", + ", {} star{}": ", {} étoile{}", + ", {} label": ", étiquette {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Téléchargement depuis {host}. Une fois le téléchargement terminé et vérifié, LightCraft l’installe, {after}.", + "{:.1} MB": "{:.1} Mo", + "Running on the processor: {}.": "Exécution sur le processeur : {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Conservé dans le dossier « denoise » de la bibliothèque ; lorsque la limite est atteinte, les fichiers les plus anciens inutilisés par les photos sont supprimés en premier.", + "Denoising {name}: {done} of {total} tiles{}": "Réduction du bruit de {name} : {done} sur {total} tuile{}", + "{size} GB": "{size} Go", + "{} of {} from {host}": "{} sur {} depuis {host}", + "{} KB": "{} Ko", + "{} MB": "{} Mo", + "Making the denoised picture… {}%": "Création de l’image débruitée… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Entraîné sur : {provenance}", + " · {queued} waiting": " · {queued} en attente", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Affiche les conditions du modèle, puis télécharge {} depuis {host}, l’installe et commence à l’utiliser. Aucune autre donnée n’est envoyée.", + "In line for AI Denoise ({ahead} ahead)…": "En attente de la réduction du bruit par IA ({ahead} devant)…", + "Running on {}.": "En cours d’exécution sur {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Placer dans « {parent} »", + "Delete the keyword “{name}”?": "Supprimer le mot-clé « {name} » ?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Il est retiré de {count} photo{}, avec les mots-clés qu’il contient. Annuler le rétablit.", + "Create Keyword Tag Inside “{name}”…": "Créer un mot-clé dans « {name} »…", + "“{parent}” has a “{name}” already. Merge the two?": "« {parent} » contient déjà « {name} ». Fusionner les deux ?", + "There is a “{name}” at the top level already. Merge the two?": "Il existe déjà « {name} » au premier niveau. Fusionner les deux ?", + "Added “{keyword}” to {n} photo{}": "« {keyword} » ajouté à {n} photo{}", + "On {have} of {of} selected photos": "Sur {have} des {of} photos sélectionnées", + "Remove “{name}”": "Retirer « {name} »", + "There is a keyword set “{name}” already": "Un jeu de mots-clés « {name} » existe déjà", + "Added {n} keyword{}": "Mots-clés ajoutés : {n}{:.0}", + "; {n} gained synonyms": " ; avec de nouveaux synonymes : {n}", + "Exported {n} keyword{}": "Mots-clés exportés : {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One n’importe pas une liste dont un mot-clé contient ; , < ou > : {names}", + " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}" +} \ No newline at end of file diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json new file mode 100644 index 000000000..a978e9831 --- /dev/null +++ b/crates/ui-egui/locales/fr.json @@ -0,0 +1,1865 @@ +{ + " (library default)": " (valeur par défaut de la bibliothèque)", + " (inverted)": " (inversée)", + " · best of its burst": " · la meilleure de sa rafale", + " · in a burst": " · dans une rafale", + "% of all commits": "% de tous les commits", + "+ Group": "+ Groupe", + "+ Rule": "+ Règle", + "0: nothing is rejected.": "0 : rien n'est rejeté.", + "1 day": "1 jour", + "2026-04 or 2026-04-12": "2026-04 ou 2026-04-12", + "= exactly": "= exactement", + "A blank template keeps the original names.": "Un modèle vide conserve les noms d'origine.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtre masque les photos de cette vue : modifiez-le ou effacez-le (Affichage → Effacer les filtres)", + "A one-time download of about": "Un téléchargement unique d'environ", + "AI Mask Model Status": "État du modèle de masques IA", + "About": "À propos", + "About LightCraft": "À propos de LightCraft", + "Active": "Active", + "Activity": "Activité", + "Actual size": "Taille réelle", + "Add": "Ajouter", + "Add Catchlight": "Ajouter un reflet dans les yeux", + "Add Folder to Local": "Ajouter le dossier à Local", + "Add Mask": "Ajouter un masque", + "Add Object Click": "Ajouter un clic sur l'objet", + "Add Point Color Sample": "Ajouter un échantillon de couleur ponctuelle", + "Add Red Eye Correction": "Ajouter une correction des yeux rouges", + "Add Remove Spot": "Ajouter une suppression de tache", + "Add Selected Photos": "Ajouter les photos sélectionnées", + "Add a rule (⌥-click: a group)": "Ajouter une règle (⌥-clic : un groupe)", + "Add in place": "Ajouter sur place", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Ajouter sur place : la bibliothèque référence les fichiers là où ils se trouvent ; rien n'est copié ni déplacé.", + "Add keyword": "Ajouter un mot-clé", + "Add number": "Ajouter un numéro", + "Add to": "Ajouter à", + "Add to Album": "Ajouter à l'album", + "Add to Favorites": "Ajouter aux favoris", + "Add to Local": "Ajouter à Local", + "Add to My Photos": "Ajouter à Mes photos", + "Add to Selected Photos": "Ajouter aux photos sélectionnées", + "Add to Target Album": "Ajouter à l'album cible", + "Add to this mask": "Ajouter à ce masque", + "Add/Subtract/Intersect Mask": "Ajouter/soustraire/intersecter un masque", + "Added to photos you import that don't already have it.": "Ajouté aux photos importées qui n'en ont pas déjà.", + "Adding": "Ajout en cours", + "Adding Lightroom photos…": "Ajout des photos Lightroom…", + "Advanced search…": "Recherche avancée…", + "After": "Après", + "After 30 days": "Après 30 jours", + "After 7 days": "Après 7 jours", + "After 90 days": "Après 90 jours", + "After a year": "Après un an", + "Album": "Album", + "Albums": "Albums", + "All": "Tout", + "All Metadata": "Toutes les métadonnées", + "All Metadata…": "Toutes les métadonnées…", + "All Photos": "Toutes les photos", + "All rights reserved": "Tous droits réservés", + "All the photos imported from this disk are included.": "Toutes les photos importées depuis ce disque sont incluses.", + "All types": "Tous les types", + "Alt Text": "Texte alternatif", + "Always": "Toujours", + "Amount": "Quantité", + "Analyzing the photo for AI masks…": "Analyse de la photo pour les masques IA…", + "Any Searchable Text": "N'importe quel texte pouvant être recherché", + "Aperture": "Ouverture", + "Application": "Application", + "Apply": "Appliquer", + "Apply Color Label Set": "Appliquer un jeu d'étiquettes de couleur", + "Apply Filter Preset": "Appliquer un préréglage de filtre", + "Apply Metadata Preset": "Appliquer un préréglage de métadonnées", + "Apply Point Curve Preset": "Appliquer un préréglage de courbe de points", + "Apply Preset": "Appliquer un préréglage", + "Apply Settings JSON": "Appliquer des réglages JSON", + "Approved": "Approuvée", + "Aqua": "Aigue-marine", + "Aqua Hue": "Teinte aigue-marine", + "Aqua Luminance": "Luminance aigue-marine", + "Aqua Saturation": "Saturation aigue-marine", + "ArtCraft Website": "Site web d'ArtCraft", + "ArtCraft — more creative apps": "ArtCraft : d'autres applications créatives", + "As Shot": "Tel quel", + "As shot": "Tel quel", + "Ascending": "Croissant", + "Aspect": "Format", + "Aspect Ratio": "Rapport d'aspect", + "Assisted Culling": "Tri assisté", + "Assisted Culling…": "Tri assisté…", + "Auto": "Auto", + "Auto Advance": "Avance automatique", + "Auto Advance Off": "Avance automatique désactivée", + "Auto Advance On": "Avance automatique activée", + "Auto Advance after rating or flagging": "Avance automatique après notation ou marquage", + "Auto Advance: move to the next photo after rating or flagging": "Avance automatique : passer à la photo suivante après notation ou marquage", + "Auto Align": "Alignement automatique", + "Auto B&W Mix": "Mixage N&B automatique", + "Auto Crop": "Recadrage automatique", + "Auto Import": "Importation automatique", + "Auto Import Now": "Importer automatiquement maintenant", + "Auto Import Settings": "Réglages d'importation automatique", + "Auto Mask": "Masque automatique", + "Auto Select": "Sélection automatique", + "Auto Settings": "Réglages automatiques", + "Auto Straighten": "Redressement automatique", + "Auto Sync": "Synchronisation automatique", + "Auto settings applied": "Réglages automatiques appliqués", + "Auto-Stack by Capture Time": "Empiler automatiquement par heure de prise de vue", + "Auto-Stack by Capture Time…": "Empiler automatiquement par heure de prise de vue…", + "Auto-Tag Photos from Tracklog": "Géolocaliser automatiquement les photos à partir d'une trace GPS", + "Auto-Tag from Tracklog": "Géolocaliser automatiquement à partir d'une trace GPS", + "Auto-Tag from Tracklog…": "Géolocaliser automatiquement à partir d'une trace GPS…", + "Automatic": "Automatique", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatique calcule la vue Détail à la taille affichée, jusqu'aux pixels de la photo elle-même. Choisissez une taille pour la plafonner : plus petit, c'est plus rapide.", + "Automatically Write Changes into XMP": "Écrire automatiquement les modifications dans XMP", + "Automatically write changes into XMP sidecars": "Écrire automatiquement les modifications dans les fichiers annexes XMP", + "Autumn Gold": "Or d'automne", + "B&W": "N&B", + "B&W Mix": "Mixage N&B", + "B&W Mixer": "Mélangeur N&B", + "Back": "Retour", + "Back Up Library…": "Sauvegarder la bibliothèque…", + "Back to Edit": "Retour à Modifier", + "Back to Edit (E)": "Retour à Modifier (E)", + "Back to Grid": "Retour à la grille", + "Background": "Arrière-plan", + "Balance": "Balance", + "Basic": "Base", + "Before": "Avant", + "Before/After (Y)": "Avant/Après (Y)", + "Before/After Settings": "Réglages Avant/Après", + "Before/After Split": "Avant/Après divisé", + "Before/After Split Top/Bottom": "Avant/Après divisé haut/bas", + "Before/After Top/Bottom": "Avant/Après haut/bas", + "Begin Interaction": "Commencer l'interaction", + "Best of Similar Shots": "Meilleure des prises similaires", + "Binary assets added": "Ressources binaires ajoutées", + "Binary assets removed": "Ressources binaires supprimées", + "Bit depth": "Profondeur de bits", + "Black & White": "Noir et blanc", + "Blacks": "Noirs", + "Bleach Bypass": "Bleach bypass", + "Bleached": "Délavé", + "Blending": "Fusion", + "Blue": "Bleu", + "Blue Hour Boost": "Boost heure bleue", + "Blue Hue": "Teinte bleue", + "Blue Luminance": "Luminance bleue", + "Blue Primary": "Primaire bleu", + "Blue Saturation": "Saturation bleue", + "Blue channel": "Canal bleu", + "Blue label": "Étiquette bleue", + "Blue/Yellow Fringe": "Frange bleue/jaune", + "Blurry photos below the score are flagged as rejects.": "Les photos floues en dessous du score sont marquées comme rejetées.", + "Boundary Warp": "Déformation des bords", + "Bright & Airy": "Lumineux et aérien", + "Bring All to Front": "Tout ramener au premier plan", + "Browse Folder": "Parcourir le dossier", + "Browse Folder…": "Parcourir le dossier…", + "Browse Profiles": "Parcourir les profils", + "Browse…": "Parcourir…", + "Brush": "Pinceau", + "Brush Settings": "Réglages du pinceau", + "Brush Stroke": "Coup de pinceau", + "Build Previews": "Générer les aperçus", + "Build Smart Previews": "Générer les aperçus dynamiques", + "By Date": "Par date", + "By day (YYYY/YYYY-MM-DD)": "Par jour (AAAA/AAAA-MM-JJ)", + "By month (YYYY/YYYY-MM)": "Par mois (AAAA/AAAA-MM)", + "CPU threads": "Threads du processeur", + "Calibration": "Calibrage", + "Camera": "Appareil photo", + "Camera make and model": "Marque et modèle de l'appareil photo", + "Cameras and Cards": "Appareils photo et cartes", + "Cancel": "Annuler", + "Cancel AI Mask Model Download": "Annuler le téléchargement du modèle de masques IA", + "Cancel Download": "Annuler le téléchargement", + "Cancel Interaction": "Annuler l'interaction", + "Cancel Preview Build": "Annuler la génération des aperçus", + "Candidate": "Candidate", + "Caption": "Légende", + "Capture Date": "Date de prise de vue", + "Capture date and time in your own format (directives below)": "Date et heure de prise de vue dans votre propre format (directives ci-dessous)", + "Capture date as YYYYMMDD": "Date de prise de vue au format AAAAMMJJ", + "Captured": "Prise de vue", + "Catalog Statistics": "Statistiques du catalogue", + "Catchlight": "Reflet dans les yeux", + "Change": "Modifier", + "Change Component Mode": "Changer le mode du composant", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Les caractères interdits dans les noms de fichiers (/ \\ : * ? \" < > |) deviennent - ; un nom existant reçoit -1, -2….", + "Check All": "Tout cocher", + "Check Output Path": "Vérifier le chemin de sortie", + "Check the files of this type": "Cocher les fichiers de ce type", + "Checking names…": "Vérification des noms…", + "Choose Another Library…": "Choisir une autre bibliothèque…", + "Choose Edit Settings to Copy": "Choisir les réglages de retouche à copier", + "Choose Edit Settings to Copy…": "Choisir les réglages de retouche à copier…", + "Choose Folder…": "Choisir un dossier…", + "Choose a photo in the grid or filmstrip": "Choisissez une photo dans la grille ou la pellicule", + "Choose…": "Choisir…", + "Cinematic": "Cinématographique", + "City": "Ville", + "Clarity": "Clarté", + "Clean Studio": "Studio épuré", + "Clear": "Effacer", + "Clear Cache": "Vider le cache", + "Clear Filters": "Effacer les filtres", + "Clear Guides": "Effacer les repères", + "Clear History": "Effacer l'historique", + "Clear Preview Cache": "Vider le cache des aperçus", + "Clear Quick Collection": "Vider la collection rapide", + "Clear Reference": "Effacer la référence", + "Clear all": "Tout effacer", + "Clear form": "Effacer le formulaire", + "Clear rating": "Effacer la note", + "Clear rating and advance": "Effacer la note et avancer", + "Cleared History": "Historique effacé", + "Click Zoom Ratio": "Rapport de zoom au clic", + "Click a shortcut, then press the new keys (Esc cancels).": "Cliquez sur un raccourci, puis appuyez sur les nouvelles touches (Échap annule).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Cliquez sur une balise pour l'insérer. Les exemples correspondent à IMG_0042.CR3, prise le 14 janv. 2026 à 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Cliquez sur les photos de la grille pour activer/désactiver le mot-clé ; Échap pour arrêter", + "Click the object to select it · ⌥-click leaves a part out": "Cliquez sur l'objet pour le sélectionner · ⌥-clic exclut une partie", + "Click the photo to pick a colour · ⇧-click adds more": "Cliquez sur la photo pour choisir une couleur · ⇧-clic en ajoute d'autres", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Cliquez sur la photo pour choisir une couleur ; ⇧-clic en ajoute d'autres (jusqu'à 5)", + "Click to clear these filters": "Cliquez pour effacer ces filtres", + "Click to restore · double-click to rename": "Cliquez pour restaurer · double-cliquez pour renommer", + "Clone": "Cloner", + "Close": "Fermer", + "Close Panel": "Fermer le panneau", + "Close Window": "Fermer la fenêtre", + "Close all": "Tout fermer", + "Close file": "Fermer le fichier", + "Cloudy": "Nuageux", + "Collapse": "Réduire", + "Collapse All Stacks": "Réduire toutes les piles", + "Collapse Stack": "Réduire la pile", + "Color": "Couleur", + "Color Grading": "Étalonnage des couleurs", + "Color Label": "Étiquette de couleur", + "Color Label Names": "Noms des étiquettes de couleur", + "Color Label Sets": "Jeux d'étiquettes de couleur", + "Color Mixer": "Mélangeur de couleurs", + "Color Noise Reduction": "Réduction du bruit de couleur", + "Color Priority": "Priorité de couleur", + "Color Range": "Plage de couleurs", + "Color label cleared": "Étiquette de couleur supprimée", + "Color space": "Espace colorimétrique", + "Color space: sRGB (AVIF)": "Espace colorimétrique : sRGB (AVIF)", + "Colors": "Couleurs", + "Combine files…": "Combiner des fichiers…", + "Command Journal": "Journal des commandes", + "Comments panel": "Panneau des commentaires", + "Commits": "Commits", + "Company": "Société", + "Compare": "Comparer", + "Compare (Shift+C)": "Comparer (Maj+C)", + "Compare Before and After": "Comparer avant et après", + "Compare needs at least two photos": "Comparer nécessite au moins deux photos", + "Component options": "Options du composant", + "Compression": "Compression", + "Concrete Cool": "Béton froid", + "Confirm before deleting": "Confirmer avant de supprimer", + "Confirm before moving photos to Recently Deleted": "Confirmer avant de déplacer des photos vers Supprimées récemment", + "Constrain Crop": "Contraindre le recadrage", + "Continue": "Continuer", + "Continue Without Saving": "Continuer sans enregistrer", + "Contrast": "Contraste", + "Contributors": "Contributeurs", + "Convert to DNG": "Convertir en DNG", + "Cool Fade": "Fondu froid", + "Cool Morning": "Matin frais", + "Copy": "Copier", + "Copy After's Settings to Before": "Copier les réglages d'Après vers Avant", + "Copy Before to After": "Copier Avant vers Après", + "Copy Before's Settings to After": "Copier les réglages d'Avant vers Après", + "Copy Edit Settings": "Copier les réglages de retouche", + "Copy History Step Settings to Before": "Copier les réglages de l'étape d'historique vers Avant", + "Copy Metadata": "Copier les métadonnées", + "Copy as DNG": "Copier en DNG", + "Copy into the library (else use the files where they are)": "Copier dans la bibliothèque (sinon, utiliser les fichiers sur place)", + "Copy pages": "Copier les pages", + "Copy the files (into the library's Originals/, or a folder you choose)": "Copier les fichiers (dans Originals/ de la bibliothèque ou dans un dossier de votre choix)", + "Copy to": "Copier vers", + "Copy to Clipboard": "Copier dans le presse-papiers", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Copier : les fichiers sont copiés dans le dossier ci-dessous et la bibliothèque utilise les copies ; les originaux restent intacts.", + "Copyright": "Copyright", + "Copyright Info URL": "URL d'informations sur le copyright", + "Copyright Status": "Statut du copyright", + "Country": "Pays", + "Create Album": "Créer un album", + "Create Album…": "Créer un album…", + "Create Folder": "Créer un dossier", + "Create Folder…": "Créer un dossier…", + "Create New Mask": "Créer un nouveau masque", + "Create PDF from clipboard": "Créer un PDF à partir du presse-papiers", + "Create PDF from file…": "Créer un PDF à partir d'un fichier…", + "Create PDF from images…": "Créer un PDF à partir d'images…", + "Create Preset": "Créer un préréglage", + "Create Preset…": "Créer un préréglage…", + "Create Proof Copy": "Créer une copie d'épreuve", + "Create Smart Album": "Créer un album intelligent", + "Create Smart Album from Filter…": "Créer un album intelligent à partir du filtre…", + "Create Smart Album…": "Créer un album intelligent…", + "Create Stack": "Créer une pile", + "Create Version": "Créer une version", + "Create Version from Step": "Créer une version à partir de l'étape", + "Create Virtual Copy": "Créer une copie virtuelle", + "Creator": "Créateur", + "Creator metadata": "Métadonnées du créateur", + "Crisp Landscape": "Paysage net", + "Crisp Vista": "Panorama net", + "Crop": "Recadrer", + "Crop & Rotate": "Recadrer et pivoter", + "Crop & Rotate (C)": "Recadrer et pivoter (C)", + "Crop Aspect": "Format du recadrage", + "Crop, Rotate, Geometry": "Recadrage, rotation, géométrie", + "Cross Process": "Traitement croisé", + "Culling": "Tri", + "Curve": "Courbe", + "Custom": "Personnalisé", + "Custom template…": "Modèle personnalisé…", + "Custom…": "Personnalisé…", + "Cut": "Couper", + "Cut pages": "Couper les pages", + "Cycle Crop Overlay": "Parcourir les incrustations de recadrage", + "Cycle Crop Overlay Orientation": "Parcourir les orientations de l'incrustation de recadrage", + "Cycle Info Overlay": "Parcourir les incrustations d'informations", + "Cycle Mask Overlay Color": "Parcourir les couleurs de l'incrustation du masque", + "Cycle Mask Overlay Mode": "Parcourir les modes d'incrustation du masque", + "Cylindrical": "Cylindrique", + "Dark & Moody": "Sombre et atmosphérique", + "Darken": "Assombrir", + "Darks": "Tons sombres", + "Date": "Date", + "Date Groups": "Groupes par date", + "Day": "Jour", + "Daylight": "Lumière du jour", + "Decrease Brush Feather": "Réduire le contour progressif du pinceau", + "Decrease Brush Size": "Réduire la taille du pinceau", + "Deep Sky": "Ciel profond", + "Defringe": "Supprimer les franges", + "Deghost Amount": "Quantité de suppression des images fantômes", + "Dehaze": "Correction du voile", + "Delete": "Supprimer", + "Delete (⌫)": "Supprimer (⌫)", + "Delete Album": "Supprimer l'album", + "Delete All Masks": "Supprimer tous les masques", + "Delete Color Label Set": "Supprimer le jeu d'étiquettes de couleur", + "Delete Component": "Supprimer le composant", + "Delete Export Preset": "Supprimer le préréglage d'exportation", + "Delete Filter Preset": "Supprimer le préréglage de filtre", + "Delete Imported Profile": "Supprimer le profil importé", + "Delete Keyword": "Supprimer le mot-clé", + "Delete Keyword Set": "Supprimer le jeu de mots-clés", + "Delete Mask": "Supprimer le masque", + "Delete Metadata Preset": "Supprimer le préréglage de métadonnées", + "Delete Permanently": "Supprimer définitivement", + "Delete Photo": "Supprimer la photo", + "Delete Photos": "Supprimer les photos", + "Delete Point Color": "Supprimer la couleur ponctuelle", + "Delete Point Color Sample": "Supprimer l'échantillon de couleur ponctuelle", + "Delete Point Curve Preset": "Supprimer le préréglage de courbe de points", + "Delete Preset": "Supprimer le préréglage", + "Delete Red Eye": "Supprimer les yeux rouges", + "Delete Red Eye Correction": "Supprimer la correction des yeux rouges", + "Delete Spot": "Supprimer la tache", + "Delete Version": "Supprimer la version", + "Delete all": "Tout supprimer", + "Delete all spots": "Supprimer toutes les taches", + "Delete mask": "Supprimer le masque", + "Delete them": "Les supprimer", + "Density": "Densité", + "Depth Range": "Plage de profondeur", + "Descending": "Décroissant", + "Describe": "Décrire", + "Describe what to keep": "Décrire ce qu'il faut conserver", + "Describe what to leave out": "Décrire ce qu'il faut exclure", + "Describe what to select": "Décrire ce qu'il faut sélectionner", + "Describe…": "Décrire…", + "Deselect": "Désélectionner", + "Deselect All": "Tout désélectionner", + "Desert Heat": "Chaleur du désert", + "Desert Warmth": "Douceur du désert", + "Desktop": "Bureau", + "Detail": "Détail", + "Detail (D)": "Détail (D)", + "Details": "Détails", + "Diagonal": "Diagonale", + "Discard Preview Cache": "Supprimer le cache des aperçus", + "Display P3": "Display P3", + "Display name": "Nom affiché", + "Display theme": "Thème d'affichage", + "Distortion": "Distorsion", + "Document properties…": "Propriétés du document…", + "Don't enlarge": "Ne pas agrandir", + "Done": "Terminé", + "Download": "Télécharger", + "Download AI Mask Model": "Télécharger le modèle de masques IA", + "Download the SAM 3 Model?": "Télécharger le modèle SAM 3 ?", + "Downloads": "Téléchargements", + "Drag along the horizon; double-click for Auto": "Faites glisser le long de l'horizon ; double-cliquez pour Auto", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Faites glisser sur un œil de la photo ; la pupille est détectée automatiquement.", + "Draw Guides": "Tracer des repères", + "Duplicate": "Dupliquer", + "Duplicate Component": "Dupliquer le composant", + "Duplicate Mask": "Dupliquer le masque", + "Duplicate and Invert Mask": "Dupliquer et inverser le masque", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Chaque / démarre un niveau de dossier ; les balises sont remplies pour chaque photo (un niveau sans métadonnées devient \"unknown\").", + "Edge": "Bord", + "Edit": "Modifier", + "Edit (E)": "Modifier (E)", + "Edit Capture Time": "Modifier l'heure de prise de vue", + "Edit Capture Time…": "Modifier l'heure de prise de vue…", + "Edit Color Label Names": "Modifier les noms des étiquettes de couleur", + "Edit Color Label Names…": "Modifier les noms des étiquettes de couleur…", + "Edit Copy for External Editor": "Modifier une copie pour l'éditeur externe", + "Edit Date": "Date de modification", + "Edit Info": "Modifier les informations", + "Edit Label Names": "Modifier les noms des étiquettes", + "Edit Label Names…": "Modifier les noms des étiquettes…", + "Edit Mask": "Modifier le masque", + "Edit Mask Component": "Modifier le composant du masque", + "Edit Sections": "Modifier les sections", + "Edit Smart Album": "Modifier l'album intelligent", + "Edit Smart Album…": "Modifier l'album intelligent…", + "Edit Spot": "Modifier la tache", + "Edit in External Editor": "Modifier dans l'éditeur externe", + "Edit settings copied": "Réglages de retouche copiés", + "Edited": "Retouchée", + "Edited or not": "Retouchées ou non", + "Editing": "Retouche", + "Effect": "Effet", + "Effects": "Effets", + "Empty Recently Deleted": "Vider Supprimées récemment", + "Enable Lens Corrections": "Activer les corrections d'objectif", + "Enable Profile Corrections": "Activer les corrections de profil", + "End Interaction": "Terminer l'interaction", + "English": "Anglais", + "Enter Full Screen": "Passer en plein écran", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Saisissez une date au format AAAA-MM-JJ HH:MM:SS", + "Erase": "Effacer", + "Every EXIF, GPS and XMP field in the file": "Tous les champs EXIF, GPS et XMP du fichier", + "Existing previews": "Aperçus existants", + "Exit": "Quitter", + "Expand": "Développer", + "Expand All Stacks": "Développer toutes les piles", + "Expand Stack": "Développer la pile", + "Expand/Collapse Stack": "Développer/réduire la pile", + "Export": "Exporter", + "Export Album with Preset": "Exporter l'album avec un préréglage", + "Export Album…": "Exporter l'album…", + "Export As…": "Exporter sous…", + "Export Group…": "Exporter le groupe…", + "Export Now": "Exporter maintenant", + "Export Point Curve Presets": "Exporter les préréglages de courbe de points", + "Export Point Curve Presets…": "Exporter les préréglages de courbe de points…", + "Export Presets": "Exporter les préréglages", + "Export Presets…": "Exporter les préréglages…", + "Export User Presets…": "Exporter les préréglages utilisateur…", + "Export stopped unexpectedly": "L'exportation s'est arrêtée de façon inattendue", + "Export with Preset": "Exporter avec un préréglage", + "Export with Previous": "Exporter avec les réglages précédents", + "Exporting": "Exportation en cours", + "Export…": "Exporter…", + "Exposure": "Exposition", + "Exposure (shutter · aperture · ISO)": "Exposition (vitesse · ouverture · ISO)", + "Extended Description": "Description étendue", + "External Editor": "Éditeur externe", + "Face Boxes": "Cadres de visages", + "Face boxes": "Cadres de visages", + "Face names written to XMP by Lightroom and other apps show up here.": "Les noms de visages écrits dans XMP par Lightroom et d'autres applications apparaissent ici.", + "Faded Matte": "Mat délavé", + "Faded Slide": "Diapositive délavée", + "Faded Urban": "Urbain délavé", + "Favorite Preset": "Préréglage favori", + "Favorite Profile": "Profil favori", + "Favorites": "Favoris", + "Feather": "Contour progressif", + "File": "Fichier", + "File & date": "Fichier et date", + "File Format": "Format de fichier", + "File Name": "Nom de fichier", + "File Path": "Chemin du fichier", + "File Size": "Taille du fichier", + "File Type": "Type de fichier", + "File name": "Nom de fichier", + "File types:": "Types de fichiers :", + "Filename": "Nom de fichier", + "Fill": "Remplir", + "Fill Edges": "Remplir les bords", + "Film": "Pellicule", + "Filmstrip": "Pellicule", + "Filmstrip (/)": "Pellicule (/)", + "Filter": "Filtre", + "Filter Bar": "Barre de filtres", + "Filter Presets": "Préréglages de filtre", + "Filter bar": "Barre de filtres", + "Filter fields": "Champs de filtre", + "Find Dust Spots": "Rechercher les taches de poussière", + "Find Missing Photos": "Rechercher les photos manquantes", + "Find Missing Photos…": "Rechercher les photos manquantes…", + "Find Similar Photos": "Rechercher des photos similaires", + "Find tools and commands…": "Rechercher des outils et des commandes…", + "Find…": "Rechercher…", + "First": "Première", + "First commit": "Premier commit", + "Fit": "Ajuster", + "Fit (F)": "Ajuster (F)", + "Fit on Screen": "Ajuster à l'écran", + "Fit to width": "Ajuster à la largeur", + "Fit visible": "Ajuster à la zone visible", + "Five stars": "Cinq étoiles", + "Fixed keys": "Touches fixes", + "Flag": "Drapeau", + "Flag as Pick": "Marquer comme sélectionnée", + "Flag as Reject": "Marquer comme rejetée", + "Flagged as Pick": "Marquée comme sélectionnée", + "Flagged as Reject": "Marquée comme rejetée", + "Flash": "Flash", + "Flip H": "Miroir H", + "Flip Horizontal": "Retourner horizontalement", + "Flip Overlay (⇧O)": "Retourner l'incrustation (⇧O)", + "Flip V": "Miroir V", + "Flip Vertical": "Retourner verticalement", + "Flow": "Flux", + "Fluorescent": "Fluorescent", + "Focal Length": "Distance focale", + "Focal length": "Distance focale", + "Focus (assisted culling)": "Mise au point (tri assisté)", + "Folder": "Dossier", + "Folder name (renamed on disk; its photos follow)": "Nom du dossier (renommé sur le disque ; ses photos suivent)", + "Folders": "Dossiers", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Les dossiers et {date} utilisent l'heure de prise de vue ; une photo qui n'en a pas utilise la date du jour.", + "Food": "Nourriture", + "Forget Unchanged Local Photos": "Oublier les photos locales inchangées", + "Forget unchanged photos": "Oublier les photos inchangées", + "Form fields panel": "Panneau des champs de formulaire", + "Format": "Format", + "Forward": "Avancer", + "Frame time": "Durée d'image", + "Frame update": "Mise à jour de l'image", + "Free": "Libre", + "Fresh & Bright": "Frais et lumineux", + "Friday": "Vendredi", + "Full": "Complet", + "Full Screen Preview": "Aperçu en plein écran", + "Full screen mode": "Mode plein écran", + "Full size": "Taille complète", + "GPS Track Log": "Trace GPS", + "GPU Rendering": "Rendu GPU", + "GPU rendering": "Rendu GPU", + "General": "Général", + "Generated demo photo": "Photo de démonstration générée", + "Geometry": "Géométrie", + "Get Develop Settings": "Obtenir les réglages de développement", + "Global Hue": "Teinte globale", + "Global Luminance": "Luminance globale", + "Global Saturation": "Saturation globale", + "Glossy": "Brillant", + "Go to History Step": "Aller à l'étape d'historique", + "Golden": "Doré", + "Golden Glow": "Lueur dorée", + "Golden Hour": "Heure dorée", + "Grab bag": "Liste de noms", + "Grain": "Grain", + "Graphic": "Graphique", + "Green": "Vert", + "Green Amount": "Quantité de vert", + "Green Hue": "Teinte verte", + "Green Hue High": "Teinte verte haute", + "Green Hue Low": "Teinte verte basse", + "Green Luminance": "Luminance verte", + "Green Primary": "Primaire vert", + "Green Saturation": "Saturation verte", + "Green channel": "Canal vert", + "Green label": "Étiquette verte", + "Grid": "Grille", + "Grid Info": "Informations de la grille", + "Gritty Street": "Rue brute", + "Group": "Groupe", + "Group by Date: Automatic": "Regrouper par date : automatique", + "Group by Day": "Regrouper par jour", + "Group by Month": "Regrouper par mois", + "Group by Year": "Regrouper par année", + "Group by date": "Regrouper par date", + "Group into Stack": "Regrouper en pile", + "Group name": "Nom du groupe", + "Guided": "Guidé", + "Guided Upright": "Upright guidé", + "Guided Upright Guides": "Repères de l'Upright guidé", + "HDR Merge": "Fusion HDR", + "HDR Merge Preview": "Aperçu de la fusion HDR", + "HDR Panorama Merge": "Fusion de panorama HDR", + "HDR Panorama Merge Preview": "Aperçu de la fusion de panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR avec les derniers réglages", + "HDR Panoramas": "Panoramas HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "panoramas HDR", + "HDR with Last Settings": "HDR avec les derniers réglages", + "HDR…": "HDR…", + "Has Edits": "Avec retouches", + "Has GPS": "Avec GPS", + "Heal": "Correction", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Corriger les petites taches sombres et floues que la poussière du capteur laisse sur les zones lisses", + "Height": "Hauteur", + "Height (px)": "Hauteur (px)", + "Help": "Aide", + "Hide LightCraft": "Masquer LightCraft", + "Hide Mask": "Masquer le masque", + "Hide Others": "Masquer les autres", + "Hides this shortcut only; the folder and its photos stay as they are": "Masque uniquement ce raccourci ; le dossier et ses photos restent intacts", + "High": "Élevé", + "High Contrast B&W": "N&B à fort contraste", + "Highlight": "Hautes lumières", + "Highlights": "Hautes lumières", + "Highlights Hue": "Teinte des hautes lumières", + "Highlights Luminance": "Luminance des hautes lumières", + "Highlights Saturation": "Saturation des hautes lumières", + "Highlights split": "Séparation des hautes lumières", + "Histogram": "Histogramme", + "History": "Historique", + "History & Activity (Y)": "Historique et activité (Y)", + "Home": "Dossier personnel", + "Horizontal": "Horizontal", + "Horizontal text": "Texte horizontal", + "Hours": "Heures", + "How to install the model": "Comment installer le modèle", + "Hue": "Teinte", + "Hue Range": "Plage de teintes", + "ISO Speed": "Sensibilité ISO", + "ISO speed": "Sensibilité ISO", + "If file exists": "Si le fichier existe", + "Image": "Image", + "Import": "Importer", + "Import Date": "Date d'importation", + "Import Lightroom Catalog": "Importer un catalogue Lightroom", + "Import Lightroom Catalog…": "Importer un catalogue Lightroom…", + "Import Photos": "Importer des photos", + "Import Photos…": "Importer des photos…", + "Import Point Curve Presets": "Importer des préréglages de courbe de points", + "Import Point Curve Presets…": "Importer des préréglages de courbe de points…", + "Import Presets": "Importer des préréglages", + "Import Presets…": "Importer des préréglages…", + "Import Profiles": "Importer des profils", + "Import Profiles & Presets…": "Importer des profils et des préréglages…", + "Import as new": "Importer comme nouvelles", + "Import from Device": "Importer depuis un appareil", + "Import from Folder…": "Importer depuis un dossier…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importez des photos avec Fichier → Importer des photos… (Cmd+Maj+I) ou déposez-les ici", + "Imported": "Importées", + "Imported Photos": "Photos importées", + "Importing": "Importation en cours", + "Importing Lightroom Catalog": "Importation du catalogue Lightroom", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "L'importation recherche des photos dans la source ; elle n'ajoute pas le dossier à Local (utilisez Local → Parcourir le dossier… pour travailler dans un dossier sans importer).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "Dans la grille de photos et la grille carrée : marquer comme sélectionnée et avancer", + "In Photo Grid and Square Grid: expand or collapse the stack": "Dans la grille de photos et la grille carrée : développer ou réduire la pile", + "In Recently Deleted": "Dans Supprimées récemment", + "In library": "Dans la bibliothèque", + "In memory — nothing is saved": "En mémoire : rien n'est enregistré", + "In use": "Utilisé", + "In-memory session: these settings last until LightCraft quits.": "Session en mémoire : ces réglages durent jusqu'à la fermeture de LightCraft.", + "Include subfolders": "Inclure les sous-dossiers", + "Increase Brush Feather": "Augmenter le contour progressif du pinceau", + "Increase Brush Size": "Augmenter la taille du pinceau", + "Info": "Infos", + "Info (I)": "Infos (I)", + "Info Overlay Off": "Incrustation d'informations désactivée", + "Info Overlay: Exposure": "Incrustation d'informations : exposition", + "Info Overlay: File": "Incrustation d'informations : fichier", + "Info overlay": "Incrustation d'informations", + "Insert at the cursor": "Insérer au curseur", + "Inspect Lightroom Catalog": "Inspecter le catalogue Lightroom", + "Inspect Photo": "Inspecter la photo", + "Inspecting Lightroom Catalog": "Inspection du catalogue Lightroom", + "Install the SAM 3 Model": "Installer le modèle SAM 3", + "Instant ’70s": "Instantané années 70", + "Interface": "Interface", + "Interface language": "Langue de l'interface", + "Intersect": "Intersecter", + "Intersect with": "Intersecter avec", + "Into one folder": "Dans un seul dossier", + "Invert": "Inverser", + "Invert Component": "Inverser le composant", + "Invert Mask": "Inverser le masque", + "JPEG (Large)": "JPEG (grand)", + "JPEG (Small)": "JPEG (petit)", + "Join the ArtCraft Discord": "Rejoindre le Discord d'ArtCraft", + "Join the ArtCraft Discord…": "Rejoindre le Discord d'ArtCraft…", + "Join the ArtCraft community on Discord": "Rejoindre la communauté ArtCraft sur Discord", + "Keep in Local": "Conserver dans Local", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Choisissez un disque avec de la place : les aperçus dynamiques font environ 1 Mo par photo. Le cache des miniatures reste dans la bibliothèque.", + "Keyboard Shortcuts": "Raccourcis clavier", + "Keyboard shortcuts": "Raccourcis clavier", + "Keyword": "Mot-clé", + "Keyword Painter": "Pinceau de mots-clés", + "Keyword Set": "Jeu de mots-clés", + "Keyword Sets": "Jeux de mots-clés", + "Keyword Suggestions": "Suggestions de mots-clés", + "Keyword to paint": "Mot-clé à appliquer", + "Keywords": "Mots-clés", + "Keywords (K)": "Mots-clés (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Les mots-clés que vous ajoutez apparaissent ici ; ⌥1–⌥9 les appliquent.", + "Label": "Étiquette", + "Landscape": "Paysage", + "Language": "Langue", + "Last": "Dernière", + "Last GPU fallback": "Dernier repli du GPU", + "Last commit": "Dernier commit", + "Last loupe render": "Dernier rendu de la loupe", + "Last view": "Dernière vue", + "Lateral Chromatic Aberration": "Aberration chromatique latérale", + "Layer": "Calque", + "Layers": "Calques", + "Leave them": "Les laisser", + "Lens": "Objectif", + "Lens Blur": "Flou de l'objectif", + "Lens Corrections": "Corrections de l'objectif", + "Level": "Niveau", + "Library": "Bibliothèque", + "Library Info": "Informations sur la bibliothèque", + "Library Originals": "Originaux de la bibliothèque", + "Library Preferences": "Préférences de la bibliothèque", + "Library State": "État de la bibliothèque", + "Library saved": "Bibliothèque enregistrée", + "Licence:": "Licence :", + "Light": "Lumière", + "LightCraft Default": "Valeurs par défaut de LightCraft", + "LightCraft Help": "Aide de LightCraft", + "LightCraft Preset": "Préréglage LightCraft", + "LightCraft Presets": "Préréglages LightCraft", + "LightCraft Website": "Site web de LightCraft", + "LightCraft could not start": "LightCraft n'a pas pu démarrer", + "LightCraft couldn't find where to keep your library.": "LightCraft n'a pas trouvé où conserver votre bibliothèque.", + "LightCraft couldn't open the library at": "LightCraft n'a pas pu ouvrir la bibliothèque à l'emplacement", + "LightCraft on GitHub": "LightCraft sur GitHub", + "LightCraft website": "Site web de LightCraft", + "LightCraft — Second Window": "LightCraft : deuxième fenêtre", + "Lightroom Classic Catalog": "Catalogue Lightroom Classic", + "Lightroom import cancelled": "Importation Lightroom annulée", + "Lightroom inspection cancelled": "Inspection Lightroom annulée", + "Lights": "Tons clairs", + "Limit file size (KB, 0 = off)": "Limiter la taille du fichier (Ko, 0 = désactivé)", + "Line delta": "Différence de lignes", + "Linear": "Linéaire", + "Linear Gradient": "Dégradé linéaire", + "Lines +/−": "Lignes +/−", + "Lines added": "Lignes ajoutées", + "Lines deleted": "Lignes supprimées", + "List Albums": "Lister les albums", + "List Develop Controls": "Lister les commandes de développement", + "List History": "Lister l'historique", + "List Presets": "Lister les préréglages", + "List Profiles": "Lister les profils", + "List this folder in Local from now on": "Afficher désormais ce dossier dans Local", + "Local": "Local", + "Local folders": "Dossiers locaux", + "Local library — no cloud account needed": "Bibliothèque locale : aucun compte cloud nécessaire", + "Locate Missing File…": "Localiser le fichier manquant…", + "Locate Photo": "Localiser la photo", + "Location": "Lieu", + "Lock": "Verrouiller", + "Lock Crop Aspect": "Verrouiller le format du recadrage", + "Locked": "Verrouillée", + "Long Edge": "Grand côté", + "Looking for photos…": "Recherche de photos…", + "Lossless": "Sans perte", + "Low": "Faible", + "Luminance": "Luminance", + "Luminance Range": "Plage de luminance", + "Lush Greens": "Verts luxuriants", + "Magenta": "Magenta", + "Magenta Hue": "Teinte magenta", + "Magenta Luminance": "Luminance magenta", + "Magenta Saturation": "Saturation magenta", + "Make Candidate the Select": "Faire de la candidate la sélection", + "Manual": "Manuel", + "Marquee zoom": "Zoom par rectangle", + "Mask Adjustment": "Réglage du masque", + "Masking": "Masquage", + "Masking (M)": "Masquage (M)", + "Masks": "Masques", + "Match": "Correspondre à", + "Match Total Exposures": "Égaliser les expositions totales", + "Matte": "Mat", + "Matte Soft": "Mat doux", + "Med": "Moyen", + "Medium Contrast": "Contraste moyen", + "Megapixels": "Mégapixels", + "Memory Budget": "Budget mémoire", + "Memory Usage": "Utilisation de la mémoire", + "Memory budget": "Budget mémoire", + "Memory for caches": "Mémoire pour les caches", + "Menu": "Menu", + "Merge": "Fusionner", + "Merge Keywords": "Fusionner les mots-clés", + "Merge into…": "Fusionner dans…", + "Merged": "Fusionnées", + "Merged PRs": "PR fusionnées", + "Merging…": "Fusion en cours…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Conditions de Meta, pas de LightCraft. Le télécharger implique de les accepter.", + "Metadata": "Métadonnées", + "Metadata Preset": "Préréglage de métadonnées", + "Metadata Presets": "Préréglages de métadonnées", + "Metadata preset": "Préréglage de métadonnées", + "Midpoint": "Point central", + "Midtones": "Tons moyens", + "Midtones Hue": "Teinte des tons moyens", + "Midtones Luminance": "Luminance des tons moyens", + "Midtones Saturation": "Saturation des tons moyens", + "Midtones split": "Séparation des tons moyens", + "Minimize": "Réduire", + "Missing Photos": "Photos manquantes", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Des métadonnées manquantes ({camera}, {title}…) laissent un vide ; un nom qui se retrouve vide conserve le nom d'origine.", + "Misty Morning": "Matin brumeux", + "Mode": "Mode", + "Model": "Modèle", + "Models": "Modèles", + "Modified Date": "Date de modification", + "Monday": "Lundi", + "Mono Red Filter": "Mono filtre rouge", + "Mono Rich": "Mono riche", + "Mono Soft": "Mono doux", + "Monochrome": "Monochrome", + "Month": "Mois", + "Moody": "Atmosphérique", + "Moody Portrait": "Portrait atmosphérique", + "More": "Plus", + "More preset options": "Plus d'options de préréglages", + "Move": "Déplacer", + "Move Album": "Déplacer l'album", + "Move Down": "Descendre", + "Move Down in Stack": "Descendre dans la pile", + "Move Folder": "Déplacer le dossier", + "Move Folder To…": "Déplacer le dossier vers…", + "Move Mask": "Déplacer le masque", + "Move Preset to Group": "Déplacer le préréglage vers un groupe", + "Move Preset to New Group": "Déplacer le préréglage vers un nouveau groupe", + "Move Up": "Monter", + "Move Up in Stack": "Monter dans la pile", + "Move in Stack": "Déplacer dans la pile", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Déplacer les fichiers (dans Originals/ de la bibliothèque ou dans un dossier de votre choix) en les retirant de la source", + "Move them": "Les déplacer", + "Move to": "Déplacer vers", + "Move to Group": "Déplacer vers un groupe", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Déplacer : les fichiers sont déplacés dans le dossier ci-dessous ; chaque original (et son fichier annexe XMP) n'est retiré de la source qu'après vérification de sa copie. Les doublons et les fichiers en échec restent à leur place.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Déplace toutes les photos importées depuis ce disque vers Supprimées récemment ; aucun fichier n'est touché", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Déplace les photos importées depuis ce dossier vers Supprimées récemment ; aucun fichier n'est touché", + "Muted": "Sourdine", + "Muted Film": "Film atténué", + "My Photos": "Mes photos", + "My Photos Panel": "Panneau Mes photos", + "Name": "Nom", + "Name (A–Z)": "Nom (A–Z)", + "Name of the folder the original is in": "Nom du dossier contenant l'original", + "Named": "Nommées", + "Named People": "Personnes nommées", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Les noms apparaissent dans le menu des étiquettes, la barre de filtres et le panneau Infos, et sont écrits dans XMP. Vide = le nom de la couleur.", + "Navigator": "Navigateur", + "Needs Work": "À retravailler", + "Neon Dusk": "Crépuscule néon", + "Neon Night": "Nuit néon", + "Nested group": "Groupe imbriqué", + "Neutral": "Neutre", + "Never": "Jamais", + "New": "Nouveau", + "New Album": "Nouvel album", + "New Album…": "Nouvel album…", + "New Folder…": "Nouveau dossier…", + "New Group…": "Nouveau groupe…", + "New Smart Album": "Nouvel album intelligent", + "New Smart Album from Filter…": "Nouvel album intelligent à partir du filtre…", + "New Smart Album…": "Nouvel album intelligent…", + "New album": "Nouvel album", + "New album…": "Nouvel album…", + "New blank PDF": "Nouveau PDF vierge", + "New name": "Nouveau nom", + "New time": "Nouvelle heure", + "New…": "Nouveau…", + "Next Photo": "Photo suivante", + "Night Blue": "Bleu nuit", + "No": "Non", + "No Camera or Card Found": "Aucun appareil photo ni carte trouvé", + "No Date Groups": "Aucun groupe par date", + "No Stars": "Aucune étoile", + "No Tool": "Aucun outil", + "No automatic versions.": "Aucune version automatique.", + "No camera": "Aucun appareil photo", + "No contributor data was built into this copy.": "Aucune donnée de contributeurs n'est incluse dans cette copie.", + "No date": "Sans date", + "No dust spots found": "Aucune tache de poussière trouvée", + "No edits yet.": "Aucune retouche pour l'instant.", + "No exposure information": "Aucune information d'exposition", + "No filter presets yet": "Aucun préréglage de filtre pour l'instant", + "No lens data embedded in this file (DNG lens opcodes only).": "Aucune donnée d'objectif intégrée à ce fichier (opcodes d'objectif DNG uniquement).", + "No masks yet. Choose a mask type above.": "Aucun masque pour l'instant. Choisissez un type de masque ci-dessus.", + "No matching commands": "Aucune commande correspondante", + "No matching photos": "Aucune photo correspondante", + "No metadata found": "Aucune métadonnée trouvée", + "No model credits were built into this copy.": "Aucun crédit de modèle n'est inclus dans cette copie.", + "No named people in these photos": "Aucune personne nommée dans ces photos", + "No named people yet": "Aucune personne nommée pour l'instant", + "No new presets": "Aucun nouveau préréglage", + "No photo selected": "Aucune photo sélectionnée", + "No photos": "Aucune photo", + "No photos found": "Aucune photo trouvée", + "No photos in this folder": "Aucune photo dans ce dossier", + "No photos match the active filters": "Aucune photo ne correspond aux filtres actifs", + "No photos match the filters (View → Clear Filters)": "Aucune photo ne correspond aux filtres (Affichage → Effacer les filtres)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Aucune photo RAW pour l'instant : les appareils photo apparaîtront ici dès que leurs photos seront dans la bibliothèque.", + "No reference photo": "Aucune photo de référence", + "No usable GPU found: rendering on the CPU": "Aucun GPU utilisable trouvé : rendu sur le processeur", + "No versions yet. Create one to keep this look.": "Aucune version pour l'instant. Créez-en une pour conserver ce rendu.", + "Noise": "Bruit", + "Noise Reduction": "Réduction du bruit", + "Non-raw photos": "Photos non RAW", + "None": "Aucun", + "Not Now": "Pas maintenant", + "Nothing to compare": "Rien à comparer", + "Nothing to survey": "Rien à afficher dans la vue multiple", + "Nudge Develop Value": "Ajuster légèrement la valeur de développement", + "OK": "OK", + "Object": "Objet", + "Object Mask": "Masque d'objet", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Les masques Objet et Décrire utilisent SAM 3, le modèle de segmentation de Meta. Il ne fait pas partie de LightCraft, et tout le reste fonctionne sans lui.", + "October 2026": "octobre 2026", + "Off": "Désactivé", + "Offset X": "Décalage X", + "Offset Y": "Décalage Y", + "Opacity": "Opacité", + "Opacity (%)": "Opacité (%)", + "Open Library": "Ouvrir la bibliothèque", + "Open Library…": "Ouvrir la bibliothèque…", + "Open in": "Ouvrir dans", + "Open in Detail": "Ouvrir dans Détail", + "Open the place in OpenStreetMap": "Ouvrir le lieu dans OpenStreetMap", + "Open…": "Ouvrir…", + "Optics": "Optique", + "Optimize Library": "Optimiser la bibliothèque", + "Optional name": "Nom facultatif", + "Options": "Options", + "Orange": "Orange", + "Orange Hue": "Teinte orange", + "Orange Luminance": "Luminance orange", + "Orange Saturation": "Saturation orange", + "Orientation": "Orientation", + "Original": "Original", + "Original + Settings": "Original + réglages", + "Original extension without the dot": "Extension d'origine sans le point", + "Original file name without its extension": "Nom du fichier d'origine sans son extension", + "Original files": "Fichiers originaux", + "Original offline · editing the smart preview": "Original hors ligne · modification de l'aperçu dynamique", + "Original offline · no smart preview": "Original hors ligne · aucun aperçu dynamique", + "Originals": "Originaux", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Autres images (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Incrustation", + "Overwrite": "Écraser", + "Pages": "Pages", + "Paint": "Peindre", + "Paint only areas like the one under the brush": "Peindre uniquement les zones similaires à celle sous le pinceau", + "Paint over a distraction on the photo to remove it.": "Peignez sur un élément gênant de la photo pour le supprimer.", + "Paint overlay": "Incrustation de peinture", + "Panorama": "Panorama", + "Panorama Merge": "Fusion de panorama", + "Panorama Merge Preview": "Aperçu de la fusion de panorama", + "Panorama with Last Settings": "Panorama avec les derniers réglages", + "Panoramas": "Panoramas", + "Panorama…": "Panorama…", + "Parametric curve": "Courbe paramétrique", + "Paste": "Coller", + "Paste Edit Settings": "Coller les réglages de retouche", + "Paste Metadata": "Coller les métadonnées", + "Paste Selected Settings": "Coller les réglages sélectionnés", + "Paste Selected Settings…": "Coller les réglages sélectionnés…", + "Paste Settings": "Coller les réglages", + "Paste Settings from Previous": "Coller les réglages de la précédente", + "Paste from Previous": "Coller depuis la précédente", + "Paste pages": "Coller les pages", + "Pastel Haze": "Brume pastel", + "People": "Personnes", + "Percent": "Pourcentage", + "Percentage": "Pourcentage", + "Performance": "Performances", + "Person": "Personne", + "Perspective": "Perspective", + "Pet Eye": "Yeux d'animal", + "Pet Eye Catchlight": "Reflet dans les yeux d'animal", + "Photo": "Photo", + "Photo Grid": "Grille de photos", + "Photo Grid (G)": "Grille de photos (G)", + "Photo Merge": "Fusion de photos", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Photo → Modifier dans l'éditeur externe (⇧⌘E) génère une copie TIFF 16 bits, l'empile avec l'original et l'ouvre ici (un nom d'application sur macOS, un chemin de programme ailleurs).", + "Photos": "Photos", + "Photos per bracket (0 = auto)": "Photos par bracketing (0 = auto)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Les photos vues dans Local mais jamais ajoutées ni modifiées quittent le catalogue lorsque leur dossier n'a pas été parcouru depuis cette durée (vérifié à l'ouverture de la bibliothèque). Les fichiers restent sur le disque ; parcourir le dossier les fait réapparaître.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Les photos qui arrivent dans ce dossier (capture connectée, scanner, application de synchronisation) sont ajoutées dès qu'elles sont complètes.", + "Pick": "Sélectionnée", + "Pick Flag": "Drapeau de sélection", + "Pick a colour on the photo with the eyedropper.": "Choisissez une couleur sur la photo avec la pipette.", + "Pick the sharpest photo of each burst": "Choisir la photo la plus nette de chaque rafale", + "Picked": "Sélectionnée", + "Picks": "Sélectionnées", + "Pictures": "Images", + "Pixels": "Pixels", + "Point Color": "Couleur ponctuelle", + "Point Curve": "Courbe de points", + "Point Curve Presets": "Préréglages de courbe de points", + "Point curve": "Courbe de points", + "Portrait": "Portrait", + "Position": "Position", + "Preferences": "Préférences", + "Preferences…": "Préférences…", + "Prepare AI Masks": "Préparer les masques IA", + "Preset": "Préréglage", + "Preset name": "Nom du préréglage", + "Presets": "Préréglages", + "Presets & Profiles": "Préréglages et profils", + "Presets (Shift+P)": "Préréglages (Maj+P)", + "Presets and profiles": "Préréglages et profils", + "Presets ▾": "Préréglages ⌄", + "Press keys…": "Appuyez sur les touches…", + "Preview": "Aperçu", + "Preview Build Progress": "Progression de la génération des aperçus", + "Preview only": "Aperçu uniquement", + "Preview size": "Taille de l'aperçu", + "Previews": "Aperçus", + "Previous Photo": "Photo précédente", + "Print": "Imprimer", + "Print…": "Imprimer…", + "Profile": "Profil", + "Profile Browser": "Navigateur de profils", + "Profile Distortion": "Distorsion du profil", + "Profile Menu": "Menu des profils", + "Profile Vignetting": "Vignetage du profil", + "Profile:": "Profil :", + "Profiles": "Profils", + "Projection": "Projection", + "Proof copy created": "Copie d'épreuve créée", + "Properties": "Propriétés", + "Public domain": "Domaine public", + "Pupil Size": "Taille de la pupille", + "Purple": "Violet", + "Purple Amount": "Quantité de violet", + "Purple Hue": "Teinte violette", + "Purple Hue High": "Teinte violette haute", + "Purple Hue Low": "Teinte violette basse", + "Purple Luminance": "Luminance violette", + "Purple Saturation": "Saturation violette", + "Put the locations you removed from Local back (no files change)": "Remettre dans Local les emplacements que vous en avez retirés (aucun fichier n'est modifié)", + "Quality": "Qualité", + "Query Photos": "Interroger les photos", + "Quick Collection": "Collection rapide", + "Quick Develop": "Développement rapide", + "Quiet Green": "Vert calme", + "Quit": "Quitter", + "Quit Anyway": "Quitter quand même", + "Quit LightCraft": "Quitter LightCraft", + "Quit with unsaved changes?": "Quitter avec des modifications non enregistrées ?", + "RAW decoding": "Décodage RAW", + "Radial": "Radial", + "Radial Gradient": "Dégradé radial", + "Radius": "Rayon", + "Random": "Aléatoire", + "Range": "Plage", + "Rate ★": "Noter ★", + "Rate ★ and advance": "Noter ★ et avancer", + "Rate ★★": "Noter ★★", + "Rate ★★ and advance": "Noter ★★ et avancer", + "Rate ★★★": "Noter ★★★", + "Rate ★★★ and advance": "Noter ★★★ et avancer", + "Rate ★★★★": "Noter ★★★★", + "Rate ★★★★ and advance": "Noter ★★★★ et avancer", + "Rate ★★★★★": "Noter ★★★★★", + "Rate ★★★★★ and advance": "Noter ★★★★★ et avancer", + "Rating": "Note", + "Rating cleared": "Note effacée", + "Ratings & flags": "Notes et drapeaux", + "Raw": "RAW", + "Raw default": "Valeur par défaut RAW", + "Raw defaults": "Valeurs par défaut RAW", + "Raw files": "Fichiers RAW", + "Raw photos": "Photos RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Photos RAW au format DNG, avec les retouches intégrées. Les options de taille, de couleur et de sortie ne s'appliquent pas.", + "Read Metadata from File": "Lire les métadonnées du fichier", + "Read mode": "Mode lecture", + "Read the SAM License": "Lire la licence SAM", + "Reading": "Lecture en cours", + "Reading Lightroom catalog…": "Lecture du catalogue Lightroom…", + "Reading folder…": "Lecture du dossier…", + "Real name": "Nom réel", + "Recent": "Récents", + "Recent Keywords": "Mots-clés récents", + "Recently Added": "Ajoutées récemment", + "Recently Deleted": "Supprimées récemment", + "Red": "Rouge", + "Red Eye": "Yeux rouges", + "Red Hue": "Teinte rouge", + "Red Luminance": "Luminance rouge", + "Red Primary": "Primaire rouge", + "Red Saturation": "Saturation rouge", + "Red channel": "Canal rouge", + "Red label": "Étiquette rouge", + "Red/Cyan Fringe": "Frange rouge/cyan", + "Redo": "Rétablir", + "Reference": "Référence", + "Reference View": "Vue de référence", + "Reference the files where they are": "Référencer les fichiers sur place", + "Refine": "Affiner", + "Refine AI Mask Detail": "Affiner le détail du masque IA", + "Refine Edges": "Affiner les bords", + "Refine Mask Edges": "Affiner les bords du masque", + "Refine Saturation": "Affiner la saturation", + "Refining the mask's detail…": "Affinage du détail du masque…", + "Refresh Source": "Actualiser la source", + "Refresh Source (/)": "Actualiser la source (/)", + "Reject": "Rejeter", + "Reject below focus": "Rejeter en dessous de la netteté", + "Rejected": "Rejetée", + "Rejects": "Rejetées", + "Reliability": "Fiabilité", + "Relink Photo": "Relier à nouveau la photo", + "Reload Changed Files": "Recharger les fichiers modifiés", + "Reload from Disk": "Recharger depuis le disque", + "Remove": "Retirer", + "Remove (H)": "Supprimer (H)", + "Remove CA": "Supprimer l'AC", + "Remove Chromatic Aberration": "Supprimer l'aberration chromatique", + "Remove Disk from Library": "Retirer le disque de la bibliothèque", + "Remove Disk from Library…": "Retirer le disque de la bibliothèque…", + "Remove Face Box": "Retirer le cadre de visage", + "Remove Folder from Library": "Retirer le dossier de la bibliothèque", + "Remove a filter above, or choose Clear all": "Retirez un filtre ci-dessus ou choisissez Tout effacer", + "Remove from Album": "Retirer de l'album", + "Remove from Favorites": "Retirer des favoris", + "Remove from Library…": "Retirer de la bibliothèque…", + "Remove from Local": "Retirer de Local", + "Remove from Photo": "Retirer de la photo", + "Remove from Selected Photos": "Retirer des photos sélectionnées", + "Remove from Stack": "Retirer de la pile", + "Remove from Target Album": "Retirer de l'album cible", + "Remove from survey": "Retirer de la vue multiple", + "Remove location info": "Supprimer les informations de lieu", + "Remove shortcut": "Supprimer le raccourci", + "Remove this face box (undo with Edit ▸ Undo)": "Retirer ce cadre de visage (annuler avec Édition → Annuler)", + "Remove this filter": "Retirer ce filtre", + "Remove this group": "Retirer ce groupe", + "Remove this rule": "Retirer cette règle", + "Rename": "Renommer", + "Rename Album": "Renommer l'album", + "Rename Component": "Renommer le composant", + "Rename Folder": "Renommer le dossier", + "Rename Folder…": "Renommer le dossier…", + "Rename Keyword": "Renommer le mot-clé", + "Rename Keyword…": "Renommer le mot-clé…", + "Rename Mask": "Renommer le masque", + "Rename Photos": "Renommer les photos", + "Rename Photos…": "Renommer les photos…", + "Rename Preset": "Renommer le préréglage", + "Rename Preview": "Aperçu du renommage", + "Rename Template Tags": "Balises du modèle de renommage", + "Rename Version": "Renommer la version", + "Rename…": "Renommer…", + "Rendering": "Rendu", + "Rendering…": "Rendu en cours…", + "Reorder Masks": "Réorganiser les masques", + "Reset": "Réinitialiser", + "Reset All": "Tout réinitialiser", + "Reset All Curves": "Réinitialiser toutes les courbes", + "Reset All Keyboard Shortcuts": "Réinitialiser tous les raccourcis clavier", + "Reset Before to Import State": "Réinitialiser Avant à l'état d'importation", + "Reset Control": "Réinitialiser la commande", + "Reset Crop": "Réinitialiser le recadrage", + "Reset Edits": "Réinitialiser les retouches", + "Reset Parametric Curve": "Réinitialiser la courbe paramétrique", + "Reset Section": "Réinitialiser la section", + "Reset Tone Curve": "Réinitialiser la courbe des tonalités", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Réinitialiser toutes les courbes : courbes de points (tous les canaux) et paramétrique (double-cliquez sur un canal pour réinitialiser uniquement celui-ci)", + "Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)", + "Reshuffle": "Mélanger à nouveau", + "Resize Face Box": "Redimensionner le cadre de visage", + "Resize to": "Redimensionner à", + "Resolution (ppi)": "Résolution (ppp)", + "Restore": "Restaurer", + "Restore (no shortcut)": "Restaurer (sans raccourci)", + "Restore Library from Backup…": "Restaurer la bibliothèque depuis une sauvegarde…", + "Restore Version": "Restaurer la version", + "Restore keeps their edits; Import as new deletes the old record first.": "Restaurer conserve leurs retouches ; Importer comme nouvelles supprime d'abord l'ancien enregistrement.", + "Restore them": "Les restaurer", + "Retouch": "Retouche", + "Reverse the order": "Inverser l'ordre", + "Revert": "Rétablir l'original", + "Review": "Révision", + "Review Import": "Vérifier l'importation", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic droit sur une photo → Définir comme photo de référence, puis Affichage → Vue de référence (Maj+R)", + "Right-click a preset to delete it": "Clic droit sur un préréglage pour le supprimer", + "Rights Usage Terms": "Conditions d'utilisation des droits", + "Rotate": "Pivoter", + "Rotate Crop": "Pivoter le recadrage", + "Rotate Crop Aspect": "Pivoter le format du recadrage", + "Rotate Left": "Pivoter à gauche", + "Rotate Right": "Pivoter à droite", + "Roughness": "Rugosité", + "Roundness": "Arrondi", + "Rules": "Règles", + "SAM 3 download stopped: it resumes where it left off next time.": "Téléchargement de SAM 3 interrompu : il reprendra là où il s'est arrêté la prochaine fois.", + "Same as raw default": "Identique à la valeur par défaut RAW", + "Sample Mask Color": "Échantillonner la couleur du masque", + "Sample a colour on the photo": "Échantillonner une couleur sur la photo", + "Saturation": "Saturation", + "Saturation Range": "Plage de saturation", + "Saturday": "Samedi", + "Save": "Enregistrer", + "Save As…": "Enregistrer sous…", + "Save Color Label Set": "Enregistrer le jeu d'étiquettes de couleur", + "Save Current Filter as Preset…": "Enregistrer le filtre actuel comme préréglage…", + "Save Current Keywords as Set…": "Enregistrer les mots-clés actuels comme jeu…", + "Save Export Preset": "Enregistrer le préréglage d'exportation", + "Save Filter Preset": "Enregistrer le préréglage de filtre", + "Save Keyword Set": "Enregistrer le jeu de mots-clés", + "Save Metadata Preset": "Enregistrer le préréglage de métadonnées", + "Save Metadata Preset…": "Enregistrer le préréglage de métadonnées…", + "Save Metadata to File": "Enregistrer les métadonnées dans le fichier", + "Save Point Curve Preset": "Enregistrer le préréglage de courbe de points", + "Save Point Curve…": "Enregistrer la courbe de points…", + "Save as Preset": "Enregistrer comme préréglage", + "Save as Set…": "Enregistrer comme jeu…", + "Save as Smart Album…": "Enregistrer comme album intelligent…", + "Save as set": "Enregistrer comme jeu", + "Save as…": "Enregistrer sous…", + "Save preset": "Enregistrer le préréglage", + "Saving Lightroom import index…": "Enregistrement de l'index d'importation Lightroom…", + "Scale": "Échelle", + "Scan failed": "Échec de l'analyse", + "Scanning": "Analyse en cours", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Les scores restent sur les photos : filtrez ou créez des albums intelligents avec Mise au point et Meilleure des prises similaires.", + "Screen": "Écran", + "Scroll menu down": "Faire défiler le menu vers le bas", + "Scroll menu up": "Faire défiler le menu vers le haut", + "Search": "Rechercher", + "Search Photos": "Rechercher des photos", + "Search commands or keys": "Rechercher des commandes ou des touches", + "Search…": "Rechercher…", + "Seasons": "Saisons", + "Second Window": "Deuxième fenêtre", + "Select": "Sélectionner", + "Select All": "Tout sélectionner", + "Select Luminance Range": "Sélectionner une plage de luminance", + "Select Mask": "Sélectionner le masque", + "Select Photos": "Sélectionner des photos", + "Select Spot": "Sélectionner la tache", + "Select a photo to edit": "Sélectionnez une photo à modifier", + "Select by": "Sélectionner par", + "Select by Flag, Rating or Label": "Sélectionner par drapeau, note ou étiquette", + "Select photos, then choose View → Survey (N)": "Sélectionnez des photos, puis choisissez Affichage → Vue multiple (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Sélectionnez deux photos, puis choisissez Affichage → Comparer (Maj+C)", + "Selected": "Sélectionnée", + "Selecting…": "Sélection en cours…", + "Selenium Tone": "Virage au sélénium", + "Send Feedback…": "Envoyer des commentaires…", + "Sepia Tone": "Virage sépia", + "Sequence number zero-padded to N digits (1–9)": "Numéro de séquence complété par des zéros sur N chiffres (1–9)", + "Sequence number, counting from the start number": "Numéro de séquence, à partir du numéro de départ", + "Services": "Services", + "Set": "Définir", + "Set Album Cover": "Définir la couverture de l'album", + "Set Before": "Définir Avant", + "Set Color Label": "Définir l'étiquette de couleur", + "Set Crop": "Définir le recadrage", + "Set Develop Values": "Définir les valeurs de développement", + "Set Flag": "Définir le drapeau", + "Set Image Zoom and Pan": "Définir le zoom et le déplacement de l'image", + "Set Keyboard Shortcut": "Définir le raccourci clavier", + "Set Mask Adjustments": "Définir les réglages du masque", + "Set Point Curve": "Définir la courbe de points", + "Set Profile": "Définir le profil", + "Set Rating": "Définir la note", + "Set Top of Stack": "Définir le haut de la pile", + "Set as Album Cover": "Définir comme couverture de l'album", + "Set as Before": "Définir comme Avant", + "Set as Reference Photo": "Définir comme photo de référence", + "Set as Target Album": "Définir comme album cible", + "Set as Target Album (B adds to it)": "Définir comme album cible (B y ajoute)", + "Set as Top of Stack": "Définir comme haut de la pile", + "Set date & time": "Définir la date et l'heure", + "Set name": "Nom du jeu", + "Set the mix from the photo's colours": "Définir le mixage à partir des couleurs de la photo", + "Settings": "Réglages", + "Settings file problem": "Problème avec le fichier de réglages", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Réglages de départ des nouvelles photos RAW. Les modifier n'affecte pas les photos déjà présentes dans la bibliothèque.", + "Settings pasted": "Réglages collés", + "Settings to include": "Réglages à inclure", + "Settings…": "Réglages…", + "Shade": "Ombre", + "Shadow": "Ombre", + "Shadows": "Ombres", + "Shadows Hue": "Teinte des ombres", + "Shadows Luminance": "Luminance des ombres", + "Shadows Saturation": "Saturation des ombres", + "Shadows Tint": "Nuance des ombres", + "Shadows split": "Séparation des ombres", + "Sharpen": "Accentuer", + "Sharpening": "Accentuation", + "Sharpness": "Netteté", + "Shift": "Décaler", + "Shift by": "Décaler de", + "Short Edge": "Petit côté", + "Show": "Afficher", + "Show All": "Tout afficher", + "Show Clipping": "Afficher l'écrêtage", + "Show Deghost Overlay": "Afficher l'incrustation de suppression des images fantômes", + "Show Hidden Local Locations": "Afficher les emplacements Local masqués", + "Show Luminance Map": "Afficher la carte de luminance", + "Show Mask": "Afficher le masque", + "Show Mask Overlay": "Afficher l'incrustation du masque", + "Show Mask Pins": "Afficher les épingles de masque", + "Show Original": "Afficher l'original", + "Show Photo Counts": "Afficher le nombre de photos", + "Show Photos with Keyword": "Afficher les photos avec le mot-clé", + "Show Pins": "Afficher les épingles", + "Show Source": "Afficher la source", + "Show Thumbnails": "Afficher les miniatures", + "Show commands without a shortcut": "Afficher les commandes sans raccourci", + "Show destination gamut warning": "Afficher l'avertissement de gamut de destination", + "Show display gamut warning": "Afficher l'avertissement de gamut de l'écran", + "Show file names": "Afficher les noms de fichiers", + "Show in Explorer": "Afficher dans l'Explorateur", + "Show in File Manager": "Afficher dans le gestionnaire de fichiers", + "Show in Finder": "Afficher dans le Finder", + "Show on Map": "Afficher sur la carte", + "Show overlay (O)": "Afficher l'incrustation (O)", + "Show ratings, flags and edit badges": "Afficher les notes, les drapeaux et les pastilles de retouche", + "Show the Navigator while zoomed in": "Afficher le Navigateur lors du zoom", + "Show the template tags; click one to insert it": "Afficher les balises du modèle ; cliquez sur l'une d'elles pour l'insérer", + "Show/Hide Mask": "Afficher/masquer le masque", + "Show/hide My Photos panel": "Afficher/masquer le panneau Mes photos", + "Shutter speed": "Vitesse d'obturation", + "Side panels": "Panneaux latéraux", + "Sidecar names": "Noms des fichiers annexes", + "Size": "Taille", + "Size (% of short edge)": "Taille (% du petit côté)", + "Size limit": "Limite de taille", + "Skip": "Ignorer", + "Sky": "Ciel", + "Slideshow": "Diaporama", + "Slideshow paused": "Diaporama en pause", + "Slideshow resumed": "Diaporama repris", + "Slideshow · Space pauses · Esc ends": "Diaporama · Espace pour mettre en pause · Échap pour terminer", + "Smart Album": "Album intelligent", + "Smart Album Rule Fields": "Champs de règles d'album intelligent", + "Smart Preview Status": "État des aperçus dynamiques", + "Smart Previews Location": "Emplacement des aperçus dynamiques", + "Smart preview available": "Aperçu dynamique disponible", + "Smart previews": "Aperçus dynamiques", + "Smart previews folder changed": "Dossier des aperçus dynamiques modifié", + "Smoothness": "Lissage", + "Soft B&W": "N&B doux", + "Soft Pastel": "Pastel doux", + "Soft Proofing": "Épreuvage à l'écran", + "Soft Skin": "Peau douce", + "Sort": "Trier", + "Sort by": "Trier par", + "Source": "Source", + "Source code on GitHub": "Code source sur GitHub", + "Spherical": "Sphérique", + "Spiral": "Spirale", + "Split Stack": "Scinder la pile", + "Spring Fresh": "Fraîcheur printanière", + "Square Grid": "Grille carrée", + "Square Grid (G toggles)": "Grille carrée (G pour basculer)", + "Square Grid: show file names and formats": "Grille carrée : afficher les noms et formats de fichiers", + "Stack": "Pile", + "Stack photos taken within this time of each other:": "Empiler les photos prises à moins de cet intervalle les unes des autres :", + "Stacks": "Piles", + "Standard": "Standard", + "Star rating (0–5)": "Note en étoiles (0–5)", + "Start at": "Commencer à", + "Start number": "Numéro de départ", + "Starting": "Démarrage", + "Startup": "Démarrage", + "State / Province": "État / province", + "Stop": "Arrêter", + "Stop Building Previews": "Arrêter la génération des aperçus", + "Stop Using as Target Album": "Ne plus utiliser comme album cible", + "Stopping…": "Arrêt en cours…", + "Straighten": "Redresser", + "Straighten Tool": "Outil Redresser", + "Strong Contrast": "Contraste fort", + "Style": "Style", + "Subfolder": "Sous-dossier", + "Subfolder name": "Nom du sous-dossier", + "Subject": "Sujet", + "Subtract": "Soustraire", + "Subtract from this mask": "Soustraire de ce masque", + "Suggestions": "Suggestions", + "Summer Haze": "Brume estivale", + "Sunday": "Dimanche", + "Survey": "Vue multiple", + "Survey (N)": "Vue multiple (N)", + "Swap": "Permuter", + "Swap Aspect (X)": "Permuter le format (X)", + "Swap Before and After": "Permuter Avant et Après", + "Swap Before and After Settings": "Permuter les réglages Avant et Après", + "Swap Compare Photos": "Permuter les photos comparées", + "Switch light / dark theme": "Basculer entre thème clair / sombre", + "Sync Settings": "Synchroniser les réglages", + "System": "Système", + "System Info": "Informations système", + "System Info…": "Informations système…", + "System default": "Valeur par défaut du système", + "Table": "Table", + "Tags": "Balises", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Balises : voir Balises à côté du modèle. Les fichiers sont renommés sur le disque (avec leurs fichiers annexes XMP) ; les noms existants reçoivent -1, -2…", + "Take a snapshot": "Créer un instantané", + "Targeted Adjustment": "Réglage ciblé", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster les couleurs à cet endroit", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Réglage ciblé : faites glisser vers le haut/bas sur la photo pour ajuster la courbe à cet endroit", + "Teal & Amber": "Bleu-vert et ambre", + "Teal & Orange": "Bleu-vert et orange", + "Temp": "Temp.", + "Temperature": "Température", + "Template": "Modèle", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Session temporaire : votre bibliothèque n'est pas ouverte et rien de ce que vous faites ici n'est enregistré.", + "Text": "Texte", + "Text direction": "Sens du texte", + "Texture": "Texture", + "That includes the photos in the folders inside it.": "Cela inclut les photos des dossiers qu'il contient.", + "That key can't be used as a shortcut": "Cette touche ne peut pas servir de raccourci", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Le modèle SAM 3 est installé : les masques Objet et Décrire sont prêts.", + "The download didn't work:": "Le téléchargement a échoué :", + "The number at the end of the original name (IMG_0042 → 0042)": "Le numéro à la fin du nom d'origine (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "L'extension d'origine est toujours ajoutée ; {ext} la place aussi à l'intérieur du nom.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Les fichiers originaux, inchangés, chacun avec un fichier annexe XMP contenant ses retouches.", + "The photo has these settings": "La photo a ces réglages", + "Theme": "Thème", + "They can be restored from Recently Deleted until it is emptied.": "Elles peuvent être restaurées depuis Supprimées récemment tant que cet espace n'est pas vidé.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Elles passent dans Supprimées récemment et peuvent être restaurées ; aucun fichier sur le disque n'est touché.", + "Thirds": "Tiers", + "This Computer": "Cet ordinateur", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Cette version ne peut pas encore télécharger le modèle. Pour l'installer à la main, placez model.safetensors, vocab.json et merges.txt de facebook/sam3 (Meta) dans le dossier ci-dessus : Objet et Décrire fonctionnent dès qu'ils s'y trouvent.", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Ce dossier n'est pas disponible (disque déconnecté ?). Les aperçus dynamiques ne sont ni générés ni utilisés tant qu'il n'est pas de retour ou que vous n'avez pas choisi un autre dossier.", + "This photo can't be opened": "Cette photo ne peut pas être ouverte", + "Threshold": "Seuil", + "Thumbnail cache": "Cache des miniatures", + "Thursday": "Jeudi", + "Time zone": "Fuseau horaire", + "Tint": "Nuance", + "Title": "Titre", + "Title metadata": "Métadonnées du titre", + "Toggle Keyword from Set": "Activer/désactiver un mot-clé du jeu", + "Toggle Mask": "Activer/désactiver le masque", + "Toggle Section": "Activer/désactiver la section", + "Toggle Zoom": "Basculer le zoom", + "Tone Curve": "Courbe des tonalités", + "Tools": "Outils", + "Top Level": "Niveau supérieur", + "Transfer": "Transfert", + "Treatment": "Traitement", + "Triangle": "Triangle", + "Try Again": "Réessayer", + "Try Saving Again": "Réessayer d'enregistrer", + "Tuesday": "Mardi", + "Tungsten": "Tungstène", + "Turn Off": "Désactiver", + "Turn on Include subfolders, or pick another folder under Local": "Activez Inclure les sous-dossiers ou choisissez un autre dossier dans Local", + "Type": "Type", + "Uncheck All": "Tout décocher", + "Uncheck the files of this type": "Décocher les fichiers de ce type", + "Undo": "Annuler", + "Unedited": "Non retouchées", + "Unedited raws": "RAW non retouchés", + "Unflag": "Retirer le drapeau", + "Unflagged": "Sans drapeau", + "Ungroup Stack": "Dissocier la pile", + "Unknown Date": "Date inconnue", + "Unknown tags stay as typed — check the preview for a {typo}.": "Les balises inconnues restent telles que saisies : vérifiez l'aperçu pour repérer une {typo}.", + "Unrated": "Non notée", + "Unreadable": "Illisible", + "Update": "Mettre à jour", + "Update Mask Shape": "Mettre à jour la forme du masque", + "Update Preset with Current Settings": "Mettre à jour le préréglage avec les réglages actuels", + "Update Rules from Current Filter": "Mettre à jour les règles à partir du filtre actuel", + "Update Version": "Mettre à jour la version", + "Update Version with Current Settings": "Mettre à jour la version avec les réglages actuels", + "Update with Current Settings": "Mettre à jour avec les réglages actuels", + "Updated the edits saved in the external editor": "Retouches enregistrées dans l'éditeur externe mises à jour", + "Upright": "Upright", + "Urban": "Urbain", + "Use Keyword Set": "Utiliser le jeu de mots-clés", + "Use Library Folder": "Utiliser le dossier de la bibliothèque", + "Use camera-specific defaults": "Utiliser les valeurs par défaut propres à l'appareil photo", + "Use the GPU for rendering": "Utiliser le GPU pour le rendu", + "User Presets": "Préréglages utilisateur", + "Username": "Nom d'utilisateur", + "Value": "Valeur", + "Variance": "Variance", + "Version": "Version", + "Version name": "Nom de la version", + "Versions": "Versions", + "Versions (Shift+V)": "Versions (Maj+V)", + "Vertical": "Vertical", + "Vertical text": "Texte vertical", + "Vibrance": "Vibrance", + "Videos": "Vidéos", + "View": "Affichage", + "Viewing and sharing": "Affichage et partage", + "Vignette": "Vignette", + "Vignetting": "Vignetage", + "Vintage": "Vintage", + "Virtual Copy": "Copie virtuelle", + "Visible": "Visible", + "Visualize Spots": "Visualiser les taches", + "Visualize Spots (A)": "Visualiser les taches (A)", + "Visualize range": "Visualiser la plage", + "Vivid": "Éclatant", + "Vivid Pop": "Couleurs éclatantes", + "Warm Film": "Film chaud", + "Warm Glow": "Lueur chaude", + "Warm Print": "Tirage chaud", + "Warm Table": "Table chaleureuse", + "Watched folder": "Dossier surveillé", + "Watermark": "Filigrane", + "Wednesday": "Mercredi", + "What's New": "Nouveautés", + "When rated or hovered": "Lors de la notation ou du survol", + "White Balance": "Balance des blancs", + "White Balance Selector": "Sélecteur de balance des blancs", + "White Balance Selector (W)": "Sélecteur de balance des blancs (W)", + "Whites": "Blancs", + "Width": "Largeur", + "Width (% of photo)": "Largeur (% de la photo)", + "Width (px)": "Largeur (px)", + "Width × Height": "Largeur × hauteur", + "Will be replaced": "Sera remplacée", + "Will be restored": "Sera restaurée", + "Window": "Fenêtre", + "Winter Blue": "Bleu hivernal", + "XMP Sidecar Preferences": "Préférences des fichiers annexes XMP", + "XMP sidecars": "Fichiers annexes XMP", + "Year": "Année", + "Yellow": "Jaune", + "Yellow Hue": "Teinte jaune", + "Yellow Luminance": "Luminance jaune", + "Yellow Saturation": "Saturation jaune", + "Yellow label": "Étiquette jaune", + "Yes": "Oui", + "You can close this: the download continues, and resumes if interrupted.": "Vous pouvez fermer cette fenêtre : le téléchargement continue et reprend s'il est interrompu.", + "Your Name": "Votre nom", + "Your library couldn't be opened": "Votre bibliothèque n'a pas pu être ouverte", + "Zoom": "Zoom", + "Zoom 100%": "Zoom 100 %", + "Zoom In": "Zoom avant", + "Zoom Out": "Zoom arrière", + "Zoom to Fit": "Zoom pour ajuster", + "Zoom to page level": "Zoom au niveau de la page", + "a literal %": "un % littéral", + "a removed photo": "une photo supprimée", + "all": "toutes", + "and": "et", + "any": "au moins une", + "are empty": "sont vides", + "aren't empty": "ne sont pas vides", + "blue": "bleu", + "comma, separated": "séparés, par des virgules", + "contains": "contient", + "copyrighted": "protégée par copyright", + "day 01–31": "jour 01–31", + "days": "jours", + "discarding smart previews": "suppression des aperçus dynamiques", + "doesn't contain": "ne contient pas", + "e.g. sky · the red car · car, road": "p. ex. sky · the red car · car, road (en anglais)", + "ends with": "se termine par", + "false": "non", + "green": "vert", + "hour 00–23": "heure 00–23", + "hours": "heures", + "image": "image", + "includes the folders inside it": "inclut les dossiers qu'il contient", + "is": "est", + "is <": "est <", + "is >": "est >", + "is after": "est postérieur à", + "is before": "est antérieur à", + "is between": "est compris entre", + "is empty": "est vide", + "is in the last": "est dans les derniers", + "is unknown": "est inconnu", + "is ≤": "est ≤", + "is ≥": "est ≥", + "isn't": "n'est pas", + "isn't empty": "n'est pas vide", + "isn't in the last": "n'est pas dans les derniers", + "keep names — or e.g. {date}_{seq:3}": "conserver les noms, ou p. ex. {date}_{seq:3}", + "latitude, longitude": "latitude, longitude", + "memory only": "mémoire uniquement", + "minute": "minute", + "month 01–12": "mois 01–12", + "months": "mois", + "none": "aucune", + "none (CPU rendering)": "aucun (rendu par le processeur)", + "none of": "aucune des", + "not available here": "non disponible ici", + "of the following rules:": "des règles suivantes :", + "or": "ou", + "pick": "sélectionnée", + "previews": "aperçus", + "publicDomain": "domaine public", + "purple": "violet", + "raw": "RAW", + "red": "rouge", + "reject": "rejetée", + "saved in": "enregistré dans", + "second": "seconde", + "smart previews": "aperçus dynamiques", + "starts with": "commence par", + "the photos in view": "les photos de la vue", + "the selected photos": "les photos sélectionnées", + "this photo": "cette photo", + "true": "oui", + "unknown": "inconnu", + "video": "vidéo", + "weeks": "semaines", + "year, 2 digits": "année, 2 chiffres", + "year, 4 digits": "année, 4 chiffres", + "years": "années", + "yellow": "jaune", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} est l'heure de prise de vue ; une photo qui n'en a pas utilise l'heure d'importation.", + "© 2026 Your Name": "© 2026 Votre nom", + "© Your Name": "© Votre nom", + "− harder border · + softer border": "− bord plus dur · + bord plus doux", + "≤ at most": "≤ au plus", + "≥ at least": "≥ au moins", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "Any camera": "N'importe quel appareil photo", + "Any lens": "N'importe quel objectif", + "Any keyword": "N'importe quel mot-clé", + "Label Set": "Jeu d'étiquettes", + ", picked": ", sélectionnée", + ", rejected": ", rejetée", + "Preview only: editing the camera's embedded JPEG": "Aperçu uniquement : modification du JPEG intégré de l'appareil photo", + "Captured from": "Prises à partir du", + "Captured until": "Prises jusqu'au", + "Only": "Uniquement", + "Open Log Folder": "Ouvrir le dossier des journaux", + "Back to this person": "Retour à cette personne", + "AI Denoise": "Débruitage IA", + "AI Denoise Model": "Modèle de débruitage IA", + "AI Denoise is off: press Use on a model.": "Le débruitage IA est désactivé : appuyez sur Utiliser sur un modèle.", + "AI Denoise needs a model. Install one in Settings.": "Le débruitage IA nécessite un modèle. Installez-en un dans les Réglages.", + "Accept & Download": "Accepter et télécharger", + "Already installed. Remove it before installing a replacement.": "Déjà installé. Supprimez-le avant d'installer un remplaçant.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatique utilise la carte graphique lorsqu'elle est plus rapide que le processeur : les deux sont chronométrés sur cet ordinateur la première fois qu'une photo est générée.", + "Cache": "Cache", + "Checking and installing the model…": "Vérification et installation du modèle…", + "Checking…": "Vérification…", + "Clear cache": "Vider le cache", + "Denoise Models": "Modèles de débruitage", + "Denoise Settings": "Réglages de débruitage", + "Denoise all photos with an Amount": "Débruiter toutes les photos ayant une Quantité", + "Downloaded and checked. Installing…": "Téléchargé et vérifié. Installation en cours…", + "Failed its last test": "Échec lors de son dernier test", + "Graphics card": "Carte graphique", + "I have read these terms and accept them for my own use": "J'ai lu ces conditions et je les accepte pour mon propre usage", + "Inspect Denoise Model": "Inspecter le modèle de débruitage", + "Install Denoise Model": "Installer le modèle de débruitage", + "Install from file…": "Installer à partir d'un fichier…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installez votre propre modèle ONNX avec denoise-model.json à côté. Cette version ne propose aucun téléchargement de modèle.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Son image débruitée n'est pas encore générée, et les images ne sont pas générées automatiquement (Réglages, Débruitage IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft ne connaît pas ce modèle ; il a donc supposé :", + "Limit": "Limite", + "Make it now": "La générer maintenant", + "Make the denoised picture of the photos I am looking at": "Générer l'image débruitée des photos que je regarde", + "Next up for AI Denoise…": "Prochaines photos pour le débruitage IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Rien n'est récupéré tant que vous n'avez pas accepté. Le modèle est conservé uniquement sur cet ordinateur ; LightCraft ne le téléverse ni ne le partage jamais.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Seules les photos dont la quantité de débruitage IA est supérieure à 0 sont générées. Les exportations génèrent toujours la leur si elle manque.", + "Open page": "Ouvrir la page", + "Open the model's page": "Ouvrir la page du modèle", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Ouvre la page du modèle dans votre navigateur, pour vous renseigner ou récupérer le fichier vous-même.", + "Processor": "Processeur", + "Set up AI Denoise…": "Configurer le débruitage IA…", + "Speed": "Vitesse", + "Starting…": "Démarrage…", + "Tested and works on this computer": "Testé et fonctionnel sur cet ordinateur", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Le modèle de débruitage est installé et utilisé : le curseur Quantité de débruitage IA sous Détail fonctionne désormais", + "The graphics card is set up when the next photo is made.": "La carte graphique est configurée lors de la génération de la prochaine photo.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Le modèle est conservé uniquement sur cet ordinateur. LightCraft ne le téléverse ni ne le partage jamais.", + "The processor does the work.": "Le processeur effectue le travail.", + "There is no model file to install": "Il n'y a aucun fichier de modèle à installer", + "This build cannot run denoise models.": "Cette version ne peut pas exécuter de modèles de débruitage.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Cette version n'a aucun endroit où conserver les modèles de débruitage ; le débruitage IA n'est donc pas disponible. (L'application de bureau en a un.)", + "This file cannot be used yet": "Ce fichier ne peut pas encore être utilisé", + "Tick the box to accept the model's terms first": "Cochez d'abord la case pour accepter les conditions du modèle", + "Try again": "Réessayer", + "Try the graphics card again": "Réessayer la carte graphique", + "Turn off": "Désactiver", + "Unknown licence": "Licence inconnue", + "Use": "Utiliser", + "Waiting to start…": "En attente de démarrage…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Fonctionne sur les fichiers RAW des appareils photo dotés d'un capteur standard (Bayer). Les autres fichiers conservent leur réduction de bruit normale.", + "cached picture": "image en cache", + "cached pictures": "images en cache", + "commercial use allowed": "usage commercial autorisé", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "utilisé", + "installed": "installé", + "makes AI Denoise available": "rend le débruitage IA disponible", + "non-commercial use only": "usage non commercial uniquement", + "photo has its denoised picture.": "photo a son image débruitée.", + "photo waiting": "photo en attente", + "photos have their denoised picture.": "photos ont leur image débruitée.", + "photos waiting": "photos en attente", + "starts using it for AI Denoise": "commence à l'utiliser pour le débruitage IA", + "terms unclear": "conditions peu claires", + "the graphics card": "la carte graphique", + "the graphics card is not used": "la carte graphique n'est pas utilisée", + "Cancel Denoise": "Annuler le débruitage", + "Cancel Denoise Model Download": "Annuler le téléchargement du modèle de débruitage", + "Choose Denoise Model": "Choisir le modèle de débruitage", + "Clear Denoise Cache": "Vider le cache de débruitage", + "Denoise Model Downloads": "Téléchargements de modèles de débruitage", + "Denoise Photos": "Débruiter les photos", + "Denoise Pump": "Pompe de débruitage", + "Denoise Status": "État du débruitage", + "Download Denoise Model": "Télécharger le modèle de débruitage", + "Remove Denoise Model": "Supprimer le modèle de débruitage", + "Test Denoise Model": "Tester le modèle de débruitage", + "AI Denoise is enabled on the requesting photo": "Le débruitage IA est activé sur la photo à l'origine de la demande", + "After installation, AI Denoise will turn on for the requesting photo.": "Après l'installation, le débruitage IA sera activé pour la photo à l'origine de la demande.", + "Cancel pending action": "Annuler l'action en attente", + "Install a denoise model to enable AI Denoise on this photo": "Installez un modèle de débruitage pour activer le débruitage IA sur cette photo", + "Pending photo action cancelled": "Action de photo en attente annulée", + "Pending photo action cancelled because its library or photo changed": "Action de photo en attente annulée car sa bibliothèque ou sa photo a changé", + "ONNX model": "Modèle ONNX", + "commercial use subject to GPL conditions": "usage commercial soumis aux conditions de la GPL", + "Camera local tone mapping": "Mappage local des tons de l’appareil", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Appliquer le mappage local des tons enregistré dans le fichier raw (Apple ProRAW), comme le rend l’appareil", + "Decrease Rating": "Diminuer la note", + "Increase Rating": "Augmenter la note", + "Click to type a value": "Cliquez pour saisir une valeur", + "Angle": "Angle", + "Video": "Vidéo", + "Copyrighted": "Protégé par copyright", + "Public Domain": "Domaine public", + "Unknown": "Inconnu", + "Landscape (wide)": "Paysage (large)", + "Portrait (tall)": "Portrait (haut)", + "Square": "Carré", + "In Color": "En couleur", + "No Label": "Sans étiquette", + "Choose an album…": "Choisir un album…", + "No albums yet": "Aucun album pour l'instant", + "Fix the marked rules to save this album.": "Corrigez les règles signalées pour enregistrer cet album.", + "Some rules need fixing (Edit Smart Album…):": "Certaines règles sont à corriger (Modifier l'album intelligent…) :", + "isn't a field rules know": "n'est pas un champ connu des règles", + "can't be tested that way": "ne peut pas être testé ainsi", + "is yes or no": "vaut oui ou non", + "needs a number": "demande un nombre", + "needs a time like 1/250 or 2": "demande une durée comme 1/250 ou 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "demande une date comme 2026, 2026-08 ou 2026-08-14", + "has the first date after the second": "a la première date après la seconde", + "needs two values": "demande deux valeurs", + "needs a number more than 0": "demande un nombre supérieur à 0", + "needs hours, days, weeks, months or years": "demande des heures, jours, semaines, mois ou années", + "isn't one of the choices": "ne fait pas partie des choix", + "needs something to look for (or use “is empty”)": "demande quelque chose à chercher (ou utilisez « est vide »)", + "needs something to look for (or use “isn't empty”)": "demande quelque chose à chercher (ou utilisez « n'est pas vide »)", + "needs text": "demande du texte", + "can't match any rating from 0 to 5": "ne peut correspondre à aucune note de 0 à 5", + "needs an album": "demande un album", + "names an album that no longer exists": "désigne un album qui n'existe plus", + "can't test a folder; choose an album in it": "ne peut pas tester un dossier ; choisissez un album qu'il contient", + "This group is empty: add a rule or remove it.": "Ce groupe est vide : ajoutez une règle ou supprimez-le.", + "needs something to look for (or use “are empty”)": "demande quelque chose à chercher (ou utilisez « sont vides »)", + "needs something to look for (or use “aren't empty”)": "demande quelque chose à chercher (ou utilisez « ne sont pas vides »)", + "Mo": "Lu", + "Tu": "Ma", + "We": "Me", + "Th": "Je", + "Fr": "Ve", + "Sa": "Sa", + "Su": "Di", + "Pick a date": "Choisir une date", + "would make this album include itself through other smart albums": "ferait que cet album s'inclue lui-même via d'autres albums intelligents", + "Search albums": "Rechercher des albums", + "No albums match": "Aucun album ne correspond", + "This is the album you're editing": "C'est l'album que vous modifiez", + "It tests this album, so testing it back would loop": "Il teste cet album : le tester en retour créerait une boucle", + "Copy Name": "Nom de la copie", + "In a Stack": "Dans une pile", + "File Extension": "Extension de fichier", + "Video Duration": "Durée de la vidéo", + "Keyword Count": "Nombre de mots-clés", + "People Count": "Nombre de personnes", + "Shutter Speed": "Vitesse d'obturation", + "Cropped": "Recadrée", + "Keywords & People": "Mots-clés et personnes", + "Description": "Description", + "Camera Info": "Infos de l'appareil", + "Develop": "Développement", + "Keyword List": "Liste des mots-clés", + "Filter Keywords": "Filtrer les mots-clés", + "No keywords yet": "Aucun mot-clé pour l'instant", + "No keywords match": "Aucun mot-clé ne correspond", + "Keyword Name": "Nom du mot-clé", + "Synonyms": "Synonymes", + "Separated by commas": "Séparés par des virgules", + "Keyword Tag Options": "Options du mot-clé", + "Include on Export": "Inclure à l'exportation", + "Export Containing Keywords": "Exporter les mots-clés parents", + "Export Synonyms": "Exporter les synonymes", + "Create Keyword Tag": "Créer un mot-clé", + "Edit Keyword Tag": "Modifier le mot-clé", + "Create": "Créer", + "No photo has it; the keywords below it go too.": "Aucune photo ne l'a ; les mots-clés qu'il contient sont aussi supprimés.", + "Edit Keyword Tag…": "Modifier le mot-clé…", + "Put New Keywords Inside This Keyword": "Placer les nouveaux mots-clés dans celui-ci", + "Purge Unused Keywords": "Purger les mots-clés inutilisés", + "Delete Keyword…": "Supprimer le mot-clé…", + "Drop here for the top level": "Déposez ici pour le niveau supérieur", + "Their photos and the keywords below them come together under one keyword.": "Leurs photos et les mots-clés qu'ils contiennent sont réunis sous un seul mot-clé.", + "Select photos to give them keywords.": "Sélectionnez des photos pour leur attribuer des mots-clés.", + "Add to All Selected Photos": "Ajouter à toutes les photos sélectionnées", + "Will Export": "Sera exporté", + "No keywords are exported": "Aucun mot-clé n'est exporté", + "& Containing": "et parents", + "Edit Set…": "Modifier le jeu…", + "Edit Keyword Set": "Modifier le jeu de mots-clés", + "Keywords (⌥1–⌥9)": "Mots-clés (⌥1–⌥9)", + "A keyword set needs a name": "Un jeu de mots-clés doit avoir un nom", + "Save as a new set": "Enregistrer comme nouveau jeu", + "Recent Keywords is built in: choose another name": "Mots-clés récents est intégré : choisissez un autre nom", + "Import Keywords…": "Importer des mots-clés…", + "Export Keywords…": "Exporter les mots-clés…", + "Import Keywords": "Importer des mots-clés", + "Export Keywords": "Exporter les mots-clés", + "Keyword Lists": "Listes de mots-clés", + "Synchronize Folder…": "Synchroniser le dossier…", + "Synchronize Folder": "Synchroniser le dossier", + "Synchronize": "Synchroniser", + "Scanning…": "Analyse…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Trouver les photos ajoutées à ce dossier sur le disque ou qui en manquent, et les fichiers annexes XMP modifiés par d'autres applications", + "Bring the library up to date with this folder and the folders inside it:": "Mettre la bibliothèque à jour avec ce dossier et ses sous-dossiers :", + "Import new photos": "Importer les nouvelles photos", + "Remove missing photos from the library": "Retirer les photos manquantes de la bibliothèque", + "Read metadata updates from XMP sidecars": "Lire les métadonnées mises à jour dans les fichiers annexes XMP", + "The library is up to date with this folder.": "La bibliothèque est à jour avec ce dossier.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Les photos manquantes vont dans Supprimées récemment et peuvent être restaurées ; aucun fichier sur le disque n'est touché.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Lire un fichier annexe remplace la note, les étiquettes, les métadonnées et les retouches de la photo par son contenu.", + "Skipped: files already in the library elsewhere": "Ignorés : fichiers déjà ailleurs dans la bibliothèque", + "Skipped: files that can't be read": "Ignorés : fichiers illisibles", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Ce dossier est introuvable : il a été déplacé ou renommé, ou son disque n'est pas connecté.", + "Relink photos whose file was renamed or moved": "Relier les photos dont le fichier a été renommé ou déplacé", + "A folder is already being synchronized": "Un dossier est déjà en cours de synchronisation", + "Synchronizing…": "Synchronisation…", + "Relinked": "Reliées", + "Removed": "Retirées", + "Metadata read": "Métadonnées lues", + "Failed": "Échecs", + "Stopped": "Arrêté", + "Stopped by an error (see the log)": "Arrêté par une erreur (voir le journal)", + "Not saved": "Non enregistré", + "A folder is being synchronized": "Un dossier est en cours de synchronisation", + "Wait for the import to finish": "Attendez la fin de l'importation" +} diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index b0e0f2f99..e4976420b 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -206,5 +206,20 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "「{parent}」の中に入れる", + "Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}", + "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…", + "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?", + "There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?", + "Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}", + "On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚", + "Remove “{name}”": "「{name}」を削除", + "There is a keyword set “{name}” already": "キーワードセット「{name}」はすでにあります", + "Added {n} keyword{}": "{n}件のキーワードを追加しました{:.0}", + "; {n} gained synonyms": "。{n}件に同義語を追加", + "Exported {n} keyword{}": "{n}件のキーワードを書き出しました{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One は ; , < > を含むキーワードがあるリストを読み込みません: {names}", + " — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b05baac24..4d9c8ae7f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -503,8 +503,8 @@ "HDR panorama": "HDRパノラマ", "HDR panoramas": "HDRパノラマ", "HDR with Last Settings": "前回の設定でHDR合成", - "Has Edits": "編集あり", - "Has GPS": "GPS情報あり", + "Has Edits": "編集", + "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", "Home": "ホーム", "Hours": "時間", @@ -1734,5 +1734,134 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Decrease Rating": "評価を下げる", + "Increase Rating": "評価を上げる", + "Click to type a value": "クリックして値を入力", + "Angle": "角度", + "Video": "動画", + "Copyrighted": "著作権あり", + "Public Domain": "パブリックドメイン", + "Unknown": "不明", + "Landscape (wide)": "横長", + "Portrait (tall)": "縦長", + "Square": "正方形", + "In Color": "カラー", + "No Label": "ラベルなし", + "smart album value|Yes": "あり", + "smart album value|No": "なし", + "In a Stack": "スタック", + "Cropped": "切り抜き", + "Choose an album…": "アルバムを選択…", + "No albums yet": "アルバムはまだありません", + "Fix the marked rules to save this album.": "このアルバムを保存するには、印の付いた条件を修正してください。", + "Some rules need fixing (Edit Smart Album…):": "修正が必要な条件があります(スマートアルバムを編集…):", + "isn't a field rules know": "不明な項目です", + "can't be tested that way": "この方法では判定できません", + "is yes or no": "「あり」か「なし」を選んでください", + "needs a number": "数値が必要です", + "needs a time like 1/250 or 2": "1/250 や 2 のようなシャッター速度が必要です", + "needs a date like 2026, 2026-08 or 2026-08-14": "2026、2026-08、2026-08-14 のような日付が必要です", + "has the first date after the second": "最初の日付が2番目の日付より後です", + "needs two values": "2つの値が必要です", + "needs a number more than 0": "0より大きい数値が必要です", + "needs hours, days, weeks, months or years": "時間・日・週・月・年のいずれかが必要です", + "isn't one of the choices": "選択肢にありません", + "needs something to look for (or use “is empty”)": "検索する語句が必要です(または「が空」を使用)", + "needs something to look for (or use “isn't empty”)": "検索する語句が必要です(または「が空でない」を使用)", + "needs text": "文字列が必要です", + "can't match any rating from 0 to 5": "0〜5 のどの評価にも一致しません", + "needs an album": "アルバムを選択してください", + "names an album that no longer exists": "存在しないアルバムが指定されています", + "can't test a folder; choose an album in it": "フォルダーは判定できません。中のアルバムを選択してください", + "This group is empty: add a rule or remove it.": "このグループは空です。条件を追加するか、グループを削除してください。", + "needs something to look for (or use “are empty”)": "検索する語句が必要です(または「が空」を使用)", + "needs something to look for (or use “aren't empty”)": "検索する語句が必要です(または「が空でない」を使用)", + "Mo": "月", + "Tu": "火", + "We": "水", + "Th": "木", + "Fr": "金", + "Sa": "土", + "Su": "日", + "Pick a date": "日付を選択", + "would make this album include itself through other smart albums": "ほかのスマートアルバムを通じて、このアルバム自身を含むことになります", + "Search albums": "アルバムを検索", + "No albums match": "一致するアルバムはありません", + "This is the album you're editing": "編集中のアルバムです", + "It tests this album, so testing it back would loop": "このアルバムを条件にしているため、逆に指定すると循環します", + "Copy Name": "コピー名", + "File Extension": "拡張子", + "Video Duration": "ビデオの長さ", + "Keyword Count": "キーワード数", + "People Count": "人数", + "Shutter Speed": "シャッター速度", + "Keywords & People": "キーワードと人物", + "Description": "説明", + "Camera Info": "カメラ情報", + "Develop": "編集", + "Keyword List": "キーワードリスト", + "Filter Keywords": "キーワードを絞り込む", + "No keywords yet": "キーワードはまだありません", + "No keywords match": "一致するキーワードはありません", + "Keyword Name": "キーワード名", + "Synonyms": "同義語", + "Separated by commas": "カンマ区切り", + "Keyword Tag Options": "キーワードタグのオプション", + "Include on Export": "書き出し時に含める", + "Export Containing Keywords": "親キーワードを書き出す", + "Export Synonyms": "同義語を書き出す", + "Create Keyword Tag": "キーワードタグを作成", + "Edit Keyword Tag": "キーワードタグを編集", + "Create": "作成", + "No photo has it; the keywords below it go too.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。", + "Edit Keyword Tag…": "キーワードタグを編集…", + "Put New Keywords Inside This Keyword": "新しいキーワードをこのキーワードの中に入れる", + "Purge Unused Keywords": "使用されていないキーワードを削除", + "Delete Keyword…": "キーワードを削除…", + "Drop here for the top level": "ここにドロップすると最上位に移動", + "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。", + "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。", + "Add to All Selected Photos": "選択したすべての写真に追加", + "Will Export": "書き出されるキーワード", + "No keywords are exported": "書き出されるキーワードはありません", + "& Containing": "と親キーワード", + "Edit Set…": "セットを編集…", + "Edit Keyword Set": "キーワードセットを編集", + "Keywords (⌥1–⌥9)": "キーワード (⌥1–⌥9)", + "A keyword set needs a name": "キーワードセットには名前が必要です", + "Save as a new set": "新しいセットとして保存", + "Recent Keywords is built in: choose another name": "「最近使用したキーワード」は組み込みです。別の名前を選んでください", + "Import Keywords…": "キーワードを読み込み…", + "Export Keywords…": "キーワードを書き出し…", + "Import Keywords": "キーワードを読み込み", + "Export Keywords": "キーワードを書き出し", + "Keyword Lists": "キーワードリスト", + "Synchronize Folder…": "フォルダを同期…", + "Synchronize Folder": "フォルダを同期", + "Synchronize": "同期", + "Scanning…": "スキャン中…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "ディスク上のこのフォルダに追加された写真や見つからない写真、ほかのアプリで変更された XMP サイドカーを探します", + "Bring the library up to date with this folder and the folders inside it:": "このフォルダとその中のフォルダに合わせてライブラリを最新にします:", + "Import new photos": "新しい写真を読み込む", + "Remove missing photos from the library": "見つからない写真をライブラリから除去", + "Read metadata updates from XMP sidecars": "XMP サイドカーからメタデータの更新を読み込む", + "The library is up to date with this folder.": "ライブラリはこのフォルダと一致しています。", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "見つからない写真は「最近削除した写真」に移動し、復元できます。ディスク上のファイルには手を加えません", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "サイドカーを読み込むと、写真のレーティング、ラベル、メタデータ、編集内容がその内容に置き換わります。", + "Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル", + "Skipped: files that can't be read": "スキップ: 読み込めないファイル", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。", + "Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す", + "A folder is already being synchronized": "すでにフォルダを同期中です", + "Synchronizing…": "同期中…", + "Relinked": "リンクし直し", + "Removed": "除去", + "Metadata read": "メタデータ読み込み", + "Failed": "失敗", + "Stopped": "停止しました", + "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", + "Not saved": "保存されていません", + "A folder is being synchronized": "フォルダを同期中です", + "Wait for the import to finish": "読み込みが終わるまでお待ちください" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index ab64e082d..b6c1c23b4 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -206,5 +206,20 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Colocar dentro de “{parent}”", + "Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.", + "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?", + "There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?", + "Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}", + "On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas", + "Remove “{name}”": "Remover “{name}”", + "There is a keyword set “{name}” already": "Já existe um conjunto de palavras-chave “{name}”", + "Added {n} keyword{}": "Palavras-chave adicionadas: {n}{:.0}", + "; {n} gained synonyms": "; {n} receberam sinônimos", + "Exported {n} keyword{}": "Palavras-chave exportadas: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — O Capture One não importa uma lista com ; , < ou > em uma palavra-chave: {names}", + " — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index ab7f39734..318120d86 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1734,5 +1734,132 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Decrease Rating": "Diminuir avaliação", + "Increase Rating": "Aumentar avaliação", + "Click to type a value": "Clique para digitar um valor", + "Angle": "Ângulo", + "Video": "Vídeo", + "Copyrighted": "Protegido por direitos autorais", + "Public Domain": "Domínio público", + "Unknown": "Desconhecido", + "Landscape (wide)": "Paisagem (horizontal)", + "Portrait (tall)": "Retrato (vertical)", + "Square": "Quadrada", + "In Color": "Colorida", + "No Label": "Sem rótulo", + "Choose an album…": "Escolha um álbum…", + "No albums yet": "Ainda não há álbuns", + "Fix the marked rules to save this album.": "Corrija as regras marcadas para salvar este álbum.", + "Some rules need fixing (Edit Smart Album…):": "Algumas regras precisam ser corrigidas (Editar álbum inteligente…):", + "isn't a field rules know": "não é um campo conhecido", + "can't be tested that way": "não pode ser testado assim", + "is yes or no": "é Sim ou Não", + "needs a number": "precisa de um número", + "needs a time like 1/250 or 2": "precisa de um tempo como 1/250 ou 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "precisa de uma data como 2026, 2026-08 ou 2026-08-14", + "has the first date after the second": "a primeira data é posterior à segunda", + "needs two values": "precisa de dois valores", + "needs a number more than 0": "precisa de um número maior que 0", + "needs hours, days, weeks, months or years": "precisa de horas, dias, semanas, meses ou anos", + "isn't one of the choices": "não é nenhuma das opções", + "needs something to look for (or use “is empty”)": "precisa de algo para procurar (ou use “está vazio”)", + "needs something to look for (or use “isn't empty”)": "precisa de algo para procurar (ou use “não está vazio”)", + "needs text": "precisa de texto", + "can't match any rating from 0 to 5": "não corresponde a nenhuma avaliação de 0 a 5", + "needs an album": "precisa de um álbum", + "names an album that no longer exists": "indica um álbum que não existe mais", + "can't test a folder; choose an album in it": "não pode testar uma pasta; escolha um álbum dentro dela", + "This group is empty: add a rule or remove it.": "Este grupo está vazio: adicione uma regra ou remova-o.", + "needs something to look for (or use “are empty”)": "precisa de algo para procurar (ou use “estão vazios”)", + "needs something to look for (or use “aren't empty”)": "precisa de algo para procurar (ou use “não estão vazios”)", + "Mo": "Seg", + "Tu": "Ter", + "We": "Qua", + "Th": "Qui", + "Fr": "Sex", + "Sa": "Sáb", + "Su": "Dom", + "Pick a date": "Escolher uma data", + "would make this album include itself through other smart albums": "faria este álbum incluir a si mesmo por meio de outros álbuns inteligentes", + "Search albums": "Pesquisar álbuns", + "No albums match": "Nenhum álbum corresponde", + "This is the album you're editing": "É o álbum que você está editando", + "It tests this album, so testing it back would loop": "Ele testa este álbum, então testá-lo de volta criaria um ciclo", + "Copy Name": "Nome da cópia", + "In a Stack": "Em uma pilha", + "File Extension": "Extensão do arquivo", + "Video Duration": "Duração do vídeo", + "Keyword Count": "Número de palavras-chave", + "People Count": "Número de pessoas", + "Shutter Speed": "Velocidade do obturador", + "Cropped": "Recortada", + "Keywords & People": "Palavras-chave e pessoas", + "Description": "Descrição", + "Camera Info": "Informações da câmera", + "Develop": "Edição", + "Keyword List": "Lista de palavras-chave", + "Filter Keywords": "Filtrar palavras-chave", + "No keywords yet": "Ainda não há palavras-chave", + "No keywords match": "Nenhuma palavra-chave corresponde", + "Keyword Name": "Nome da palavra-chave", + "Synonyms": "Sinônimos", + "Separated by commas": "Separados por vírgulas", + "Keyword Tag Options": "Opções da palavra-chave", + "Include on Export": "Incluir na exportação", + "Export Containing Keywords": "Exportar palavras-chave que a contêm", + "Export Synonyms": "Exportar sinônimos", + "Create Keyword Tag": "Criar palavra-chave", + "Edit Keyword Tag": "Editar palavra-chave", + "Create": "Criar", + "No photo has it; the keywords below it go too.": "Nenhuma foto a tem; as palavras-chave abaixo dela também são excluídas.", + "Edit Keyword Tag…": "Editar palavra-chave…", + "Put New Keywords Inside This Keyword": "Colocar novas palavras-chave dentro desta", + "Purge Unused Keywords": "Excluir palavras-chave não usadas", + "Delete Keyword…": "Excluir palavra-chave…", + "Drop here for the top level": "Solte aqui para o nível superior", + "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave.", + "Select photos to give them keywords.": "Selecione fotos para dar palavras-chave a elas.", + "Add to All Selected Photos": "Adicionar a todas as fotos selecionadas", + "Will Export": "Será exportado", + "No keywords are exported": "Nenhuma palavra-chave é exportada", + "& Containing": "e as que as contêm", + "Edit Set…": "Editar conjunto…", + "Edit Keyword Set": "Editar conjunto de palavras-chave", + "Keywords (⌥1–⌥9)": "Palavras-chave (⌥1–⌥9)", + "A keyword set needs a name": "Um conjunto de palavras-chave precisa de um nome", + "Save as a new set": "Salvar como novo conjunto", + "Recent Keywords is built in: choose another name": "“Palavras-chave recentes” é fixo: escolha outro nome", + "Import Keywords…": "Importar palavras-chave…", + "Export Keywords…": "Exportar palavras-chave…", + "Import Keywords": "Importar palavras-chave", + "Export Keywords": "Exportar palavras-chave", + "Keyword Lists": "Listas de palavras-chave", + "Synchronize Folder…": "Sincronizar pasta…", + "Synchronize Folder": "Sincronizar pasta", + "Synchronize": "Sincronizar", + "Scanning…": "Verificando…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Encontrar fotos adicionadas a esta pasta no disco ou ausentes dela, e arquivos XMP auxiliares alterados por outros apps", + "Bring the library up to date with this folder and the folders inside it:": "Atualizar a biblioteca com esta pasta e as pastas dentro dela:", + "Import new photos": "Importar fotos novas", + "Remove missing photos from the library": "Remover fotos ausentes da biblioteca", + "Read metadata updates from XMP sidecars": "Ler atualizações de metadados dos arquivos XMP auxiliares", + "The library is up to date with this folder.": "A biblioteca está atualizada com esta pasta.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "As fotos ausentes vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Ler um arquivo auxiliar substitui a classificação, os rótulos, os metadados e as edições da foto pelo que ele contém.", + "Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar", + "Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado.", + "Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido", + "A folder is already being synchronized": "Uma pasta já está sendo sincronizada", + "Synchronizing…": "Sincronizando…", + "Relinked": "Revinculadas", + "Removed": "Removidas", + "Metadata read": "Metadados lidos", + "Failed": "Com falha", + "Stopped": "Interrompido", + "Stopped by an error (see the log)": "Interrompido por um erro (veja o log)", + "Not saved": "Não salvo", + "A folder is being synchronized": "Uma pasta está sendo sincronizada", + "Wait for the import to finish": "Aguarde a importação terminar" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index f3a0f647b..cd55e708f 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -206,5 +206,20 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Поместить в «{parent}»", + "Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.", + "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…", + "“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?", + "There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?", + "Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}", + "On {have} of {of} selected photos": "На {have} из {of} выбранных фото", + "Remove “{name}”": "Убрать «{name}»", + "There is a keyword set “{name}” already": "Набор ключевых слов «{name}» уже есть", + "Added {n} keyword{}": "Добавлено ключевых слов: {n}{:.0}", + "; {n} gained synonyms": "; синонимы добавлены к {n}", + "Exported {n} keyword{}": "Экспортировано ключевых слов: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не импортирует список, где в ключевом слове есть ; , < или >: {names}", + " — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 517e2613f..b51355c74 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1407,5 +1407,172 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Decrease Rating": "Уменьшить оценку", + "Increase Rating": "Увеличить оценку", + "Click to type a value": "Щёлкните, чтобы ввести значение", + "Angle": "Угол", + "Raw": "RAW", + "Video": "Видео", + "Copyrighted": "Защищено авторским правом", + "Public Domain": "Общественное достояние", + "Unknown": "Неизвестно", + "Landscape (wide)": "Альбомная", + "Portrait (tall)": "Книжная", + "Square": "Квадратная", + "In Color": "Цветное", + "No Label": "Без метки", + "Choose an album…": "Выберите альбом…", + "No albums yet": "Альбомов пока нет", + "Fix the marked rules to save this album.": "Исправьте отмеченные условия, чтобы сохранить альбом.", + "Some rules need fixing (Edit Smart Album…):": "Некоторые условия нужно исправить (Изменить смарт-альбом…):", + "isn't a field rules know": "неизвестное поле", + "can't be tested that way": "так проверить нельзя", + "is yes or no": "только «Да» или «Нет»", + "needs a number": "нужно число", + "needs a time like 1/250 or 2": "нужна выдержка вроде 1/250 или 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "нужна дата вроде 2026, 2026-08 или 2026-08-14", + "has the first date after the second": "первая дата позже второй", + "needs two values": "нужны два значения", + "needs a number more than 0": "нужно число больше 0", + "needs hours, days, weeks, months or years": "нужны часы, дни, недели, месяцы или годы", + "isn't one of the choices": "нет среди вариантов", + "needs something to look for (or use “is empty”)": "нужно указать, что искать (или выберите «пусто»)", + "needs something to look for (or use “isn't empty”)": "нужно указать, что искать (или выберите «не пусто»)", + "needs text": "нужен текст", + "can't match any rating from 0 to 5": "не подходит ни к одной оценке от 0 до 5", + "needs an album": "нужен альбом", + "names an album that no longer exists": "указан альбом, которого больше нет", + "can't test a folder; choose an album in it": "нельзя проверять папку; выберите альбом в ней", + "This group is empty: add a rule or remove it.": "Эта группа пуста: добавьте условие или удалите её.", + "needs something to look for (or use “are empty”)": "нужно указать, что искать (или выберите «пусты»)", + "needs something to look for (or use “aren't empty”)": "нужно указать, что искать (или выберите «не пусты»)", + "are empty": "пусты", + "aren't empty": "не пусты", + "contains": "содержит", + "doesn't contain": "не содержит", + "ends with": "заканчивается на", + "is": "равно", + "is <": "меньше", + "is >": "больше", + "is after": "после", + "is before": "до", + "is between": "между", + "is empty": "пусто", + "is in the last": "за последние", + "is unknown": "неизвестна", + "is ≤": "не больше", + "is ≥": "не меньше", + "isn't": "не равно", + "isn't empty": "не пусто", + "isn't in the last": "не за последние", + "starts with": "начинается с", + "Mo": "Пн", + "Tu": "Вт", + "We": "Ср", + "Th": "Чт", + "Fr": "Пт", + "Sa": "Сб", + "Su": "Вс", + "Pick a date": "Выбрать дату", + "would make this album include itself through other smart albums": "альбом включал бы сам себя через другие смарт-альбомы", + "Search albums": "Поиск альбомов", + "No albums match": "Нет подходящих альбомов", + "This is the album you're editing": "Это альбом, который вы изменяете", + "It tests this album, so testing it back would loop": "Он проверяет этот альбом, поэтому обратная проверка создаст цикл", + "Copy Name": "Имя копии", + "In a Stack": "В стопке", + "File Extension": "Расширение файла", + "Video Duration": "Длительность видео", + "Keyword Count": "Число ключевых слов", + "People Count": "Число людей", + "Shutter Speed": "Выдержка", + "Cropped": "Кадрировано", + "Keywords & People": "Ключевые слова и люди", + "Description": "Описание", + "Camera Info": "Сведения о камере", + "Develop": "Обработка", + "Pick Flag": "Флаг", + "Any Searchable Text": "Любой текст для поиска", + "Filename": "Имя файла", + "File Type": "Тип файла", + "File Format": "Формат файла", + "Edit Date": "Дата изменения", + "People": "Люди", + "Focal Length": "Фокусное расстояние", + "ISO Speed": "ISO", + "Has GPS": "Есть GPS", + "Has Edits": "Есть правки", + "Focus (assisted culling)": "Резкость (помощь в отборе)", + "Best of Similar Shots": "Лучший из похожих снимков", + "Date": "Дата", + "Color Label": "Цветовая метка", + "Keyword List": "Список ключевых слов", + "Filter Keywords": "Фильтр ключевых слов", + "No keywords yet": "Ключевых слов пока нет", + "No keywords match": "Нет подходящих ключевых слов", + "Keyword Name": "Название ключевого слова", + "Synonyms": "Синонимы", + "Separated by commas": "Через запятую", + "Keyword Tag Options": "Параметры ключевого слова", + "Include on Export": "Включать при экспорте", + "Export Containing Keywords": "Экспортировать родительские ключевые слова", + "Export Synonyms": "Экспортировать синонимы", + "Create Keyword Tag": "Создать ключевое слово", + "Edit Keyword Tag": "Изменить ключевое слово", + "Create": "Создать", + "No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены.", + "Edit Keyword Tag…": "Изменить ключевое слово…", + "Put New Keywords Inside This Keyword": "Помещать новые ключевые слова в это", + "Purge Unused Keywords": "Удалить неиспользуемые ключевые слова", + "Delete Keyword…": "Удалить ключевое слово…", + "Drop here for the top level": "Отпустите здесь для верхнего уровня", + "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом.", + "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова.", + "Person": "Человек", + "Add to All Selected Photos": "Добавить ко всем выбранным фото", + "Will Export": "Будет экспортировано", + "No keywords are exported": "Ключевые слова не экспортируются", + "& Containing": "и родительские", + "Edit Set…": "Изменить набор…", + "Edit Keyword Set": "Изменить набор ключевых слов", + "Keywords (⌥1–⌥9)": "Ключевые слова (⌥1–⌥9)", + "A keyword set needs a name": "Набору ключевых слов нужно название", + "Set name": "Название набора", + "Save as a new set": "Сохранить как новый набор", + "Recent Keywords is built in: choose another name": "«Недавние ключевые слова» — встроенный набор: выберите другое название", + "Recent Keywords": "Недавние ключевые слова", + "Import Keywords…": "Импорт ключевых слов…", + "Export Keywords…": "Экспорт ключевых слов…", + "Import Keywords": "Импорт ключевых слов", + "Export Keywords": "Экспорт ключевых слов", + "Keyword Lists": "Списки ключевых слов", + "Synchronize Folder…": "Синхронизировать папку…", + "Synchronize Folder": "Синхронизация папки", + "Synchronize": "Синхронизировать", + "Scanning…": "Сканирование…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Найти фото, добавленные в эту папку на диске или пропавшие из неё, и файлы XMP, изменённые другими приложениями", + "Bring the library up to date with this folder and the folders inside it:": "Привести медиатеку в соответствие с этой папкой и вложенными папками:", + "Import new photos": "Импортировать новые фото", + "Remove missing photos from the library": "Убрать отсутствующие фото из медиатеки", + "Read metadata updates from XMP sidecars": "Прочитать изменения метаданных из файлов XMP", + "The library is up to date with this folder.": "Медиатека соответствует этой папке.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Отсутствующие фото перемещаются в «Недавно удалённые» и могут быть восстановлены; файлы на диске не затрагиваются.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение файла XMP заменяет рейтинг, метки, метаданные и правки фото его содержимым.", + "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте", + "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён.", + "Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён", + "A folder is already being synchronized": "Папка уже синхронизируется", + "Synchronizing…": "Синхронизация…", + "Imported": "Импортировано", + "Relinked": "Связано заново", + "Removed": "Убрано", + "Metadata read": "Метаданные прочитаны", + "Failed": "Ошибки", + "Stopped": "Остановлено", + "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", + "Not saved": "Не сохранено", + "A folder is being synchronized": "Идёт синхронизация папки", + "Wait for the import to finish": "Дождитесь окончания импорта" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json new file mode 100644 index 000000000..2196c6bac --- /dev/null +++ b/crates/ui-egui/locales/uk-formats.json @@ -0,0 +1,225 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} із кількома схожими файлами: використайте «Знайти»)", + " ({} in Recently Deleted)": " ({} у нещодавно видалених)", + " · {:.0}% clipped": " · {:.0}% відсічено", + " · {failed} couldn't be rendered": " · не вдалося відтворити: {failed}", + " · {skipped} skipped (file exists)": " · пропущено: {skipped} (файл існує)", + " · {} duplicate{} skipped": " · дублікатів пропущено: {}{:.0}", + " · {} failed: {}": " · помилок: {}: {}", + " · {} moved": " · переміщено: {}", + " · {} not readable": " · не читається: {}", + " · {} original{} left at the source": " · оригіналів залишено в джерелі: {}{:.0}", + " · {} restored from Recently Deleted": " · відновлено з нещодавно видалених: {}", + " — not carried over: {}{}": " — не перенесено: {}{}", + "+{} more": "+{} ще", + ", {failed} file{} not readable": ", файлів не читається: {failed}{:.0}", + "; {kept} already had a location": "; уже мали розташування: {kept}", + "; {outside} outside its time range": "; поза часовим діапазоном маршруту: {outside}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: PR: {}, комітів: {}, рядків: +{} / −{} (Δ {}), двійкових ресурсів: +{} / −{}, {} – {}", + "Add {local_n} to My Photos": "Додати до моїх фото: {local_n}", + "Add {} Photo{}": "Додати фото: {}{:.0}", + "Add {} Photo{}, restore {}": "Додати фото: {}{:.0}, відновити: {}", + "Add “{}” to Favorites": "Додати «{}» до улюблених", + "Added {n} photo{} to My Photos": "До моїх фото додано: {n}{:.0}", + "Added {n} photo{} to “{}”": "До «{1}» додано фото: {n}{0:.0}", + "Added {what} to {name}": "{what}: додано до {name}", + "Adding photos… {} of {}": "Додавання фото… {} з {}", + "All {n} selected photos move by the same amount.": "Усі вибрані фото ({n}) зміщуються на однакове значення.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Уже в бібліотеці, у нещодавно видалених: вибрано фото: {}{:.0} · права кнопка → Відновити або Видалити назавжди, щоб імпортувати заново", + "Already in the library: {} photo{} selected in All Photos": "Уже в бібліотеці: вибрано фото в усіх фото: {}{:.0}", + "Auto ({})": "Автоматично ({})", + "Auto Import: added {} photo{}": "Автоімпорт: додано фото: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Автосинхронізація: зміни застосовуються до фото: {n}", + "Automatic ({} MB)": "Автоматично ({} МБ)", + "Build 1:1 Previews ({scope})": "Створити перегляди 1:1 ({scope})", + "Build Smart Previews ({scope})": "Створити смарт-перегляди ({scope})", + "Build Standard-Sized Previews ({scope})": "Створити перегляди стандартного розміру ({scope})", + "Building previews for {} photos…": "Створення переглядів для фото: {}…", + "Click {}:1": "Клацання {}:1", + "Couldn't open the editor: {e}": "Не вдалося відкрити редактор: {e}", + "Create {n} Virtual Copies": "Створити віртуальні копії: {n}", + "Creates {} stacks from {} of {scope}": "Буде створено стосів: {} із {} ({scope})", + "Decoder: {reason}": "Декодер: {reason}", + "Delete \"{label}\"": "Видалити «{label}»", + "Delete \"{label}\" (and the mask)": "Видалити «{label}» (і маску)", + "Delete {n} Photos": "Видалити фото: {n}", + "Delete “{current}”": "Видалити «{current}»", + "Discard Smart Previews ({scope})": "Видалити смарт-перегляди ({scope})", + "Done ({what}): {done} photo{plural}": "Готово ({what}): фото: {done}{plural:.0}", + "Duplicate \"{label}\"": "Дублювати «{label}»", + "Example: {example}": "Приклад: {example}", + "Exported {n} photo{}": "Експортовано фото: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Експортовано фото: {ok} із {total}{:.0}", + "Exported {} point curve preset{}": "Експортовано наборів налаштувань точкової кривої: {}{:.0}", + "Exported {} preset{}": "Експортовано наборів налаштувань: {}{:.0}", + "Exporting {} of {total}": "Експорт: {} із {total}", + "Filter bar — {active} active filter{}": "Панель фільтрів — активних фільтрів: {active}{:.0}", + "Focus {:.0}": "Фокус {:.0}", + "Folder “{}” (and its subfolders)": "Тека «{}» (і вкладені теки)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Знайдено відсутніх фото: {n}{:.0}; усе ще відсутні: {left}{unsure}", + "Healed {n} dust spot{}": "Усунено пилових плям: {n}{:.0}", + "Import cancelled · {} photo{} added": "Імпорт скасовано · додано фото: {}{:.0}", + "Import failed: {e}": "Помилка імпорту: {e}", + "Imported {n} point curve preset{}": "Імпортовано наборів налаштувань точкової кривої: {n}{:.0}", + "Imported {n} preset{}": "Імпортовано наборів налаштувань: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Імпортовано наборів налаштувань: {n}{:.0}, файлів не читається: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Імпортовано профілів: {profiles}{:.0} (Оглядач профілів → їхні групи)", + "Imported {} photo{}": "Імпортовано фото: {}{:.0}", + "Insert at the cursor (also written {})": "Вставити в позицію курсора (також записується як {})", + "Label Set: {name}": "Набір міток: {name}", + "LightCraft could not open its window: {e}": "LightCraft не вдалося відкрити вікно: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft не вдалося відкрити вікно: {e}\n\nПодробиці у файлі журналу: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Каталог Lightroom перевірено: фото — {photos}, колекцій — {collections}, відсутні — {missing}", + "Lightroom import complete: {imported} photos added": "Імпорт Lightroom завершено: додано фото — {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Імпорт Lightroom завершено: додано фото — {imported}, помилок — {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Імпорт Lightroom завершено: додано фото — {imported}; попередження індексу: {warning}", + "Lightroom import failed: {e}{warning}": "Помилка імпорту Lightroom: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Помилка перевірки Lightroom: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Шрифти: {} (OFL). Значки: оригінальні.", + "Matches: {}": "Збігів: {}", + "Merging… {stage} {:.0}%": "Об’єднання… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Перемістити {what} до нещодавно видалених?", + "Named ({named_n})": "З іменами ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Призначення «{k}»: клацайте фото, щоб додати або вилучити його · Esc завершує", + "Paste into {n} photo{} — only settings that were copied are pasted": "Вставка у фото: {n}{:.0} — вставляються лише скопійовані налаштування", + "Preset: {name}": "Набір налаштувань: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Лише перегляд — редагування вбудованого JPEG камери ({})", + "Preview only — {}": "Лише перегляд — {}", + "Previews ready for {done} photo{plural}": "Перегляди готові для фото: {done}{plural:.0}", + "Profile: {}": "Профіль: {}", + "Proof Copy ({})": "Пробна копія ({})", + "Proof Preview · {}": "Пробний перегляд · {}", + "Quick Develop · {n} photos": "Швидка обробка · фото: {n}", + "Quick Develop: {name}": "Швидка обробка: {name}", + "Rated {}": "Оцінено: {}", + "Reading Lightroom catalog… {done} of {total}": "Читання каталогу Lightroom… {done} із {total}", + "Reading photos… {done} of {total}": "Читання фото… {done} із {total}", + "Reading photos… {} of {}": "Читання фото… {} із {}", + "Redo {}": "Повторити {}", + "Remove “{name}” and its {count} photo{} from the library?": "Вилучити «{name}» і фото ({count}){:.0} з бібліотеки?", + "Remove “{}” from Favorites": "Вилучити «{}» з улюблених", + "Removed from {}": "Вилучено з {}", + "Removed {what} from {name}": "{what}: вилучено з {name}", + "Rename {n} Photos…": "Перейменувати фото: {n}…", + "Rename “{name}”": "Перейменувати «{name}»", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Перейменовує «{from}» на фото ({n}){:.0} (також вкладені ключові слова). Використовуйте | для рівнів, наприклад Подорожі|Італія. Наявна назва об’єднує обидва слова.", + "Rendering on the CPU: {why}": "Рендеринг на ЦП: {why}", + "Replace {} with:": "Замінити {} на:", + "Reset {name}": "Скинути: {name}", + "Reset {} Channel": "Скинути канал: {}", + "Restore {}": "Відновити {}", + "Restore {} Photo{}": "Відновити фото: {}{:.0}", + "Saved export preset “{}”": "Набір налаштувань експорту «{}» збережено", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Оцінює фокус і експозицію ({scope}) та знаходить подібні знімки з різницею в кілька секунд (серії).", + "Show {hidden} hidden location{}": "Показати приховані розташування: {hidden}{:.0}", + "Showing {n} of {}": "Показано {n} із {}", + "Smart album: {}": "Смарт-альбом: {}", + "Tagged {n} photo{} from the tracklog": "Позначено за журналом маршруту фото: {n}{:.0}", + "The SAM 3 download failed: {e}": "Не вдалося завантажити SAM 3: {e}", + "Undo {}": "Скасувати {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Невідомі теги{s:.0} {} залишаються як введено{:.0} (див. «Теги»)", + "Updated “{name}”": "«{name}» оновлено", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Версія {} — незалежно розроблена бібліотека фото й обробка RAW на чистому Rust.", + "Version: {}": "Версія: {}", + "Virtual copy “{name}” of {of}": "Віртуальна копія «{name}» з {of}", + "Working on {what} for {} photos…": "{what}: обробка фото ({})…", + "none (CPU rendering): {why}": "немає (рендеринг на ЦП): {why}", + "the {n} selected photos": "вибрані фото: {n}", + "the {} photos in view": "фото в поданні: {}", + "these {count} photos": "ці фото ({count})", + "{count} smart preview{} · {:.1} MB": "Смарт-переглядів: {count}{:.0} · {:.1} МБ", + "{f} folders (and their subfolders)": "Тек: {f} (і їхніх вкладених тек)", + "{label} Grading": "Колірне тонування: {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} на кожному вибраному фото", + "{label} {index} of {n}": "{label} {index} із {n}", + "{label}, {n} photos": "{label}, фото: {n}", + "{matching} of {t} photos": "Фото: {matching} із {t}", + "{matching} photos": "Фото: {matching}", + "{mode} spot {} of {}": "{mode}: ділянка {} із {}", + "{month}": "{month}", + "{month} {year}": "{month:02}.{year}", + "{msg} ({failed} failed)": "{msg} (помилок: {failed})", + "{name} Label": "Мітка: {name}", + "{name} Off": "{name}: вимкнено", + "{name} On": "{name}: увімкнено", + "{name} opened for editing; it is stacked with the original": "{name} відкрито для редагування й об’єднано в стос з оригіналом", + "{name} — double-click to reset it": "{name} — подвійне клацання для скидання", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Змін не вдалося записати на диск: {n}{0:.0}: {e}\nЯкщо вийти зараз, їх буде втрачено.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Змін збережено в пам’яті, але не записано на диск: {n}{:.0}: {e}\nLightCraft повторює спроби автоматично; вихід зараз призведе до їхньої втрати.{:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Змін збережено в пам’яті, але не записано на диск: {n}{:.0}: {e} — LightCraft повторить спробу", + "{n} found · {sel} selected": "Знайдено: {n} · вибрано: {sel}", + "{n} photos": "Фото: {n}", + "{n} photos in {} are hidden by the filters above": "Фільтри вище приховують фото в «{}»: {n}", + "{n} photo{}": "Фото: {n}{:.0}", + "{n} photo{} match · updates automatically as photos change": "Відповідних фото: {n}{:.0} · оновлюється автоматично зі зміною фото", + "{n} photo{} now · updates automatically as photos change": "Зараз фото: {n}{:.0} · оновлюється автоматично зі зміною фото", + "{pid} (missing)": "{pid} (відсутній)", + "{sel_n} selected · {counted}": "Вибрано: {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} (фото: {n}){:.0}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day:02}.{month:02}.{year}", + "{what} merge added": "{what}: об’єднання додано", + "{what} merge failed: {e}": "{what}: помилка об’єднання: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Директиви {{date:…}}: {}", + "{} and higher": "{} і вище", + "{} contributors · {} commits": "Учасників: {} · комітів: {}", + "{} failed": "Помилок: {}", + "{} files": "Файлів: {}", + "{} files and {f} folder{}": "Файлів: {}, тек: {f}{:.0}", + "{} item{}": "Елементів: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "Напрямних: {} із 4 — проведіть уздовж ліній, які мають бути вертикальними або горизонтальними.", + "{} photos": "Фото: {}", + "{} photos · exposures {} EV": "Фото: {} · експозиції {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Не читається фото: {}{:.0}", + "{} sample{}": "Зразків: {}{:.0}", + "{} sec": "{} с", + "{} spot(s) on this photo": "Ділянок на цьому фото: {}", + "{} — show their photos": "{} — показати фото цієї особи", + "· {dups} already in the library": "· уже в бібліотеці: {dups}", + "· {n} photo{}": "· фото: {n}{:.0}", + "… and {more} more": "… і ще {more}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft ще не може декодувати цей варіант RAW ({}). Ви редагуєте вбудований JPEG-перегляд камери, який уже містить її стиль зображення (наприклад, монохромний) і баланс білого.", + "Filtered: {label}": "Відфільтровано: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Подібних фото: {n}{:.0} · Вигляд → Скинути фільтри, щоб побачити всі", + "Moved; {} photo(s) relinked": "Переміщено; повторно пов’язано фото: {}", + "{} label (click more labels to show any of them)": "Мітка «{}» (клацніть інші мітки, щоб показати фото з будь-якою з них)", + ", {} star{}": ", зірок: {}{:.0}", + ", {} label": ", мітка: {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Завантажується з {host}. Після отримання й перевірки LightCraft установлює модель, {after}.", + "{:.1} MB": "{:.1} МБ", + "Running on the processor: {}.": "Працює на процесорі: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Зберігається в теці «denoise» бібліотеки; після досягнення ліміту першими видаляються найстаріші зображення, які не використовуються жодним фото.", + "Denoising {name}: {done} of {total} tiles{}": "Усунення шуму {name}: ділянки — {done} із {total}{}", + "{size} GB": "{size} ГБ", + "{} of {} from {host}": "{} із {} з {host}", + "{} KB": "{} КБ", + "{} MB": "{} МБ", + "Making the denoised picture… {}%": "Створення зображення з усуненим шумом… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{noun}: {n}", + "Trained on: {provenance}": "Навчено на: {provenance}", + " · {queued} waiting": " · очікують: {queued}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Показує умови моделі, потім завантажує {} з {host}, установлює й починає використовувати її. Більше нічого не надсилається.", + "In line for AI Denoise ({ahead} ahead)…": "У черзі на усунення шуму ШІ (попереду: {ahead})…", + "Running on {}.": "Працює на {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Помістити в «{parent}»", + "Delete the keyword “{name}”?": "Видалити ключове слово «{name}»?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Його буде знято з фото (кількість: {count}{:.0}) разом із вкладеними ключовими словами. Скасування поверне його.", + "Create Keyword Tag Inside “{name}”…": "Створити ключове слово в «{name}»…", + "“{parent}” has a “{name}” already. Merge the two?": "«{parent}» уже містить «{name}». Об’єднати їх?", + "There is a “{name}” at the top level already. Merge the two?": "На верхньому рівні вже є «{name}». Об’єднати їх?", + "Added “{keyword}” to {n} photo{}": "«{keyword}» додано до фото: {n}{:.0}", + "On {have} of {of} selected photos": "На {have} з {of} вибраних фото", + "Remove “{name}”": "Вилучити «{name}»", + "There is a keyword set “{name}” already": "Набір ключових слів «{name}» уже існує", + "Added {n} keyword{}": "Додано ключових слів: {n}{:.0}", + "; {n} gained synonyms": "; отримали синоніми: {n}", + "Exported {n} keyword{}": "Експортовано ключових слів: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не імпортує список, у якому ключове слово містить ; , < або >: {names}", + " — left out, as a keyword list can't hold them: {names}": " — пропущено, бо список ключових слів не може їх містити: {names}" +} diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json new file mode 100644 index 000000000..52a30fb7c --- /dev/null +++ b/crates/ui-egui/locales/uk.json @@ -0,0 +1,2059 @@ +{ + "% of all commits": "% усіх комітів", + "About": "Про програму", + "Add": "Додати", + "Add to": "Додати до", + "All": "Усе", + "Always": "Завжди", + "Amount": "Інтенсивність", + "Aperture": "Діафрагма", + "Apply": "Застосувати", + "Aqua": "Бірюзовий", + "ArtCraft Website": "Вебсайт ArtCraft", + "Aspect Ratio": "Співвідношення сторін", + "Auto": "Авто", + "Automatic": "Автоматично", + "Background": "Фон", + "Balance": "Баланс", + "Basic": "Основні", + "Binary assets added": "Додані двійкові ресурси", + "Binary assets removed": "Вилучені двійкові ресурси", + "Bit depth": "Бітова глибина", + "Black & White": "Чорно-біле", + "Blacks": "Чорні тони", + "Bleach Bypass": "Пропуск відбілювання", + "Blending": "Змішування", + "Blue": "Синій", + "Bring All to Front": "Усі вікна на передній план", + "Browse…": "Огляд…", + "Brush": "Пензель", + "Cancel": "Скасувати", + "Caption": "Підпис", + "Change": "Змінити", + "Choose…": "Вибрати…", + "City": "Місто", + "Clarity": "Чіткість", + "Clear": "Очистити", + "Clear Guides": "Очистити напрямні", + "Clear History": "Очистити історію", + "Clone": "Клонування", + "Close": "Закрити", + "Color": "Колір", + "Color Grading": "Колірне тонування", + "Color Mixer": "Змішувач кольорів", + "Color Noise Reduction": "Зменшення колірного шуму", + "Color Priority": "Пріоритет кольору", + "Color Range": "Колірний діапазон", + "Color space": "Колірний простір", + "Colors": "Кольори", + "Commits": "Коміти", + "Company": "Компанія", + "Compression": "Стиснення", + "Contrast": "Контрастність", + "Contributors": "Учасники", + "Copy": "Копіювати", + "Copyright": "Авторське право", + "Copyright Info URL": "URL інформації про авторське право", + "Copyright Status": "Статус авторського права", + "Country": "Країна", + "Crop": "Кадрування", + "Cross Process": "Перехресна обробка", + "Curve": "Крива", + "Custom": "Довільний", + "Custom…": "Довільний…", + "Darken": "Затемнення", + "Darks": "Темні тони", + "Defringe": "Прибрати облямівку", + "Dehaze": "Усунення серпанку", + "Delete": "Видалити", + "Delete Preset": "Видалити набір налаштувань", + "Density": "Щільність", + "Deselect All": "Зняти всі виділення", + "Detail": "Деталі", + "Details": "Подробиці", + "Display P3": "Display P3", + "Display name": "Відображуване ім’я", + "Distortion": "Спотворення", + "Done": "Готово", + "Download": "Завантажити", + "Duplicate": "Дублювати", + "Edge": "Край", + "Edit": "Редагування", + "Editing": "Редагування", + "Effects": "Ефекти", + "English": "English", + "Erase": "Стирання", + "Export": "Експорт", + "Export Presets": "Експортувати набори налаштувань", + "Export…": "Експортувати…", + "Exposure": "Експозиція", + "Favorites": "Улюблені", + "Feather": "Розтушовування", + "File": "Файл", + "Fill": "Заливка", + "First": "Перший", + "First commit": "Перший коміт", + "Focal Length": "Фокусна відстань", + "Grab bag": "Випадкова добірка", + "Group name": "Назва групи", + "Home": "Домашня тека", + "ISO Speed": "Чутливість ISO", + "Imported": "Імпортовані", + "Join the ArtCraft Discord": "Приєднатися до Discord ArtCraft", + "Join the ArtCraft Discord…": "Приєднатися до Discord ArtCraft…", + "Label": "Мітка", + "Last commit": "Останній коміт", + "Lens Blur": "Розмиття об’єктива", + "Line delta": "Різниця рядків", + "Linear": "Лінійний", + "Lines +/−": "Рядки +/−", + "Lines added": "Додані рядки", + "Lines deleted": "Видалені рядки", + "Lock": "Заблокувати", + "Merged PRs": "Об’єднані PR", + "Model": "Модель", + "Models": "Моделі", + "Move Down": "Перемістити вниз", + "Move Up": "Перемістити вгору", + "Name (A–Z)": "Ім’я (А–Я)", + "No contributor data was built into this copy.": "У цю копію не вбудовано дані про учасників.", + "No matching commands": "Немає відповідних команд", + "No model credits were built into this copy.": "У цю копію не вбудовано дані про використані моделі.", + "Press keys…": "Натисніть клавіші…", + "Radial": "Радіальний", + "Random": "Випадковий", + "Real name": "Справжнє ім’я", + "Recent": "Нещодавні", + "Filter": "Фільтр", + "Fit": "Умістити", + "Flag": "Прапорець", + "Flash": "Спалах", + "Flip Horizontal": "Віддзеркалити горизонтально", + "Flip Vertical": "Віддзеркалити вертикально", + "Flow": "Потік", + "Folder": "Тека", + "Format": "Формат", + "Free": "Довільно", + "Full": "Повне", + "General": "Загальні", + "Glossy": "Глянцеве", + "Grain": "Зерно", + "Graphic": "Графіка", + "Green": "Зелений", + "Grid": "Сітка", + "Group": "Група", + "Heal": "Відновлення", + "Height": "Висота", + "Help": "Довідка", + "Hide Others": "Приховати інші", + "High": "Висока", + "Highlight": "Світла ділянка", + "Highlights": "Світлі ділянки", + "Histogram": "Гістограма", + "History": "Історія", + "Horizontal": "Горизонтальна", + "Hue": "Тон", + "Import": "Імпорт", + "Import Presets": "Імпортувати набори налаштувань", + "Info": "Інформація", + "Interface": "Інтерфейс", + "Intersect": "Перетин", + "Intersect with": "Перетнути з", + "Invert": "Інвертувати", + "Invert Mask": "Інвертувати маску", + "Keyboard Shortcuts": "Клавіатурні скорочення", + "Keywords": "Ключові слова", + "Landscape": "Пейзаж", + "Language": "Мова", + "Last": "Останній", + "Lens": "Об’єктив", + "Lens Corrections": "Корекція об’єктива", + "Level": "Рівень", + "Library": "Бібліотека", + "Light": "Світло", + "Lights": "Світлі тони", + "Linear Gradient": "Лінійний градієнт", + "Location": "Розташування", + "Lossless": "Без втрат", + "Low": "Низька", + "Luminance": "Яскравість", + "Magenta": "Пурпуровий", + "Masking": "Маскування", + "Matte": "Матове", + "Merge": "Об’єднати", + "Metadata": "Метадані", + "Midpoint": "Середня точка", + "Midtones": "Середні тони", + "Minimize": "Згорнути", + "Mode": "Режим", + "Monochrome": "Монохромний", + "More": "Більше", + "Move": "Переміщення", + "Move to": "Перемістити до", + "Name": "Назва", + "Navigator": "Навігатор", + "Never": "Ніколи", + "No": "Ні", + "Noise": "Шум", + "Noise Reduction": "Зменшення шуму", + "None": "Немає", + "OK": "Гаразд", + "Object": "Об’єкт", + "Off": "Вимкнено", + "Offset X": "Зміщення X", + "Opacity": "Непрозорість", + "Orange": "Оранжевий", + "Original": "Оригінал", + "Overlay": "Накладання", + "Overwrite": "Перезаписати", + "Percent": "Відсотки", + "Performance": "Продуктивність", + "Perspective": "Перспектива", + "Photo": "Фото", + "Pixels": "Пікселі", + "Point Curve": "Точкова крива", + "Portrait": "Портрет", + "Position": "Положення", + "Preset": "Набір налаштувань", + "Presets": "Набори налаштувань", + "Preview": "Попередній перегляд", + "Print": "Друк", + "Profile": "Профіль", + "Profile:": "Профіль:", + "Pupil Size": "Розмір зіниці", + "Purple": "Фіолетовий", + "Quality": "Якість", + "Radial Gradient": "Радіальний градієнт", + "Radius": "Радіус", + "Range": "Діапазон", + "Reading": "Читання", + "Red": "Червоний", + "Red Eye": "Червоні очі", + "Redo": "Повторити", + "Remove": "Видалити", + "Rename": "Перейменувати", + "Rename…": "Перейменувати…", + "Reset": "Скинути", + "Reset Section": "Скинути розділ", + "Restore": "Відновити", + "Reverse the order": "Зворотний порядок", + "Review": "Перевірка", + "Rotate": "Повернути", + "Roughness": "Шорсткість", + "Roundness": "Округлість", + "Saturation": "Насиченість", + "Save": "Зберегти", + "Scale": "Масштаб", + "Screen": "Екран", + "Search": "Пошук", + "Select": "Вибрати", + "Select All": "Виділити все", + "Services": "Служби", + "Set Keyboard Shortcut": "Задати клавіші", + "Settings": "Налаштування", + "Settings…": "Налаштування…", + "Shadow": "Тінь", + "Shadows": "Тіні", + "Sharpen": "Підвищення різкості", + "Sharpening": "Підвищення різкості", + "Sharpness": "Різкість", + "Shift": "Shift", + "Show": "Показати", + "Show All": "Показати всі", + "Show Mask": "Показати маску", + "Show Thumbnails": "Показати мініатюри", + "Size": "Розмір", + "Sky": "Небо", + "Smoothness": "Плавність", + "Sort": "Сортування", + "Source": "Джерело", + "Source code on GitHub": "Вихідний код GitHub", + "Spiral": "Спіраль", + "Standard": "Стандартний", + "Stop": "Зупинити", + "Straighten": "Випрямити", + "Style": "Стиль", + "Subject": "Об’єкт", + "Subtract": "Відняти", + "System": "Система", + "System Info": "Інформація про систему", + "System Info…": "Інформація про систему…", + "Table": "Таблиця", + "Temperature": "Температура", + "Template": "Шаблон", + "Text": "Текст", + "Texture": "Текстура", + "Threshold": "Поріг", + "Tint": "Відтінок", + "Tone Curve": "Тональна крива", + "Tools": "Інструменти", + "Transfer": "Передавання", + "Triangle": "Трикутник", + "Undo": "Скасувати", + "Username": "Ім’я користувача", + "Variance": "Дисперсія", + "Version": "Версія", + "Vertical": "Вертикальна", + "Vibrance": "Соковитість", + "View": "Вигляд", + "Vignetting": "Віньєтування", + "Visible": "Видимий", + "Whites": "Білі тони", + "Width": "Ширина", + "Window": "Вікно", + "Yellow": "Жовтий", + "Yes": "Так", + "Zoom": "Масштаб", + "Zoom In": "Збільшити", + "Zoom Out": "Зменшити", + "Effect": "Ефект", + "Image": "Зображення", + "Layer": "Шар", + "Type": "Тип", + "Save As…": "Зберегти як…", + "Exit": "Вийти", + "New…": "Створити…", + "New": "Створити", + "Orientation": "Орієнтація", + "Layers": "Шари", + "Properties": "Властивості", + "Brush Settings": "Налаштування пензля", + "Options": "Параметри", + "Fit on Screen": "Умістити на екрані", + "Cut": "Вирізати", + "Paste": "Вставити", + "Deselect": "Зняти виділення", + "Export As…": "Експортувати як…", + "Search…": "Пошук…", + "Theme": "Тема", + "Pages": "Сторінки", + "Preferences": "Налаштування", + "Preferences…": "Налаштування…", + "Open…": "Відкрити…", + "Revert": "Повернути до збереженого", + "Print…": "Друк…", + "Find…": "Знайти…", + "Collapse": "Згорнути", + "Expand": "Розгорнути", + "Speed": "Швидкість", + " (library default)": " (типове для бібліотеки)", + " (inverted)": " (інвертовано)", + " · best of its burst": " · найкраще в серії", + " · in a burst": " · у серії", + "+ Group": "+ Група", + "+ Rule": "+ Правило", + ", picked": ", відібрано", + ", rejected": ", відхилено", + "0: nothing is rejected.": "0: нічого не відхиляти.", + "1 day": "1 день", + "2026-04 or 2026-04-12": "2026-04 або 2026-04-12", + "= exactly": "= точно", + "A blank template keeps the original names.": "Порожній шаблон зберігає початкові назви.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Фільтр приховує фото в цьому поданні: змініть або скиньте його (Вигляд → Скинути фільтри)", + "A one-time download of about": "Одноразове завантаження приблизно", + "AI Mask Model Status": "Стан моделі масок ШІ", + "About LightCraft": "Про LightCraft", + "Active": "Активне", + "Activity": "Дії", + "Add Catchlight": "Додати відблиск в оці", + "Add Folder to Local": "Додати теку до локальних", + "Add Mask": "Додати маску", + "Add Object Click": "Додати об’єкт клацанням", + "Add Point Color Sample": "Додати зразок точкового кольору", + "Add Red Eye Correction": "Додати корекцію червоних очей", + "Add Remove Spot": "Додати ділянку видалення", + "Add Selected Photos": "Додати вибрані фото", + "Add a rule (⌥-click: a group)": "Додати правило (⌥-клацання: група)", + "Add in place": "Додати на місці", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Додати на місці: бібліотека посилається на файли в їхньому розташуванні; нічого не копіюється й не переміщується.", + "Add keyword": "Додати ключове слово", + "Add number": "Додати число", + "Add to Album": "Додати до альбому", + "Add to Favorites": "Додати до улюблених", + "Add to Local": "Додати до локальних", + "Add to My Photos": "Додати до моїх фото", + "Add to Selected Photos": "Додати до вибраних фото", + "Add to Target Album": "Додати до цільового альбому", + "Add to this mask": "Додати до цієї маски", + "Add/Subtract/Intersect Mask": "Додати/відняти/перетнути маску", + "Added to photos you import that don't already have it.": "Додається до імпортованих фото, які ще не мають цього значення.", + "Adding": "Додавання", + "Adding Lightroom photos…": "Додавання фото Lightroom…", + "After": "Після", + "After 30 days": "Через 30 днів", + "After 7 days": "Через 7 днів", + "After 90 days": "Через 90 днів", + "After a year": "Через рік", + "Album": "Альбом", + "Albums": "Альбоми", + "All Metadata": "Усі метадані", + "All Metadata…": "Усі метадані…", + "All Photos": "Усі фото", + "All rights reserved": "Усі права захищено", + "All types": "Усі типи", + "Alt Text": "Альтернативний текст", + "Analyzing the photo for AI masks…": "Аналіз фото для масок ШІ…", + "Any Searchable Text": "Будь-який текст для пошуку", + "Any camera": "Будь-яка камера", + "Any keyword": "Будь-яке ключове слово", + "Any lens": "Будь-який об’єктив", + "Application": "Застосунок", + "Apply Color Label Set": "Застосувати набір кольорових міток", + "Apply Filter Preset": "Застосувати набір налаштувань фільтра", + "Apply Metadata Preset": "Застосувати набір налаштувань метаданих", + "Apply Point Curve Preset": "Застосувати набір налаштувань точкової кривої", + "Apply Preset": "Застосувати набір налаштувань", + "Apply Settings JSON": "Застосувати налаштування JSON", + "Approved": "Схвалено", + "Aqua Hue": "Тон бірюзового", + "Aqua Luminance": "Яскравість бірюзового", + "Aqua Saturation": "Насиченість бірюзового", + "ArtCraft — more creative apps": "ArtCraft — інші творчі застосунки", + "As Shot": "Як під час знімання", + "As shot": "Як під час знімання", + "Ascending": "За зростанням", + "Aspect": "Пропорції", + "Assisted Culling": "Допоміжний відбір", + "Assisted Culling…": "Допоміжний відбір…", + "Auto Advance": "Автоперехід", + "Auto Advance Off": "Вимкнути автоперехід", + "Auto Advance On": "Увімкнути автоперехід", + "Auto Advance after rating or flagging": "Автоперехід після оцінювання або позначення", + "Auto Advance: move to the next photo after rating or flagging": "Автоперехід: перейти до наступного фото після оцінювання або позначення", + "Auto Align": "Автовирівнювання", + "Auto B&W Mix": "Автоматичне змішування чорно-білого", + "Auto Crop": "Автоматичне кадрування", + "Auto Import": "Автоімпорт", + "Auto Import Now": "Виконати автоімпорт зараз", + "Auto Import Settings": "Налаштування автоімпорту", + "Auto Mask": "Автоматична маска", + "Auto Select": "Автоматичне виділення", + "Auto Settings": "Автоматичні налаштування", + "Auto Straighten": "Автоматичне випрямлення", + "Auto Sync": "Автосинхронізація", + "Auto settings applied": "Автоматичні налаштування застосовано", + "Auto-Stack by Capture Time": "Автоматично об’єднати в стоси за часом знімання", + "Auto-Stack by Capture Time…": "Автоматично об’єднати в стоси за часом знімання…", + "Auto-Tag Photos from Tracklog": "Автоматично позначити фото за журналом маршруту", + "Auto-Tag from Tracklog": "Автоматично позначити за журналом маршруту", + "Auto-Tag from Tracklog…": "Автоматично позначити за журналом маршруту…", + "Automatically Write Changes into XMP": "Автоматично записувати зміни в XMP", + "Automatically write changes into XMP sidecars": "Автоматично записувати зміни в супровідні файли XMP", + "Autumn Gold": "Осіннє золото", + "B&W": "Чорно-біле", + "B&W Mix": "Змішування чорно-білого", + "B&W Mixer": "Мікшер чорно-білого", + "Back": "Назад", + "Back to Edit": "Назад до редагування", + "Back to Edit (E)": "Назад до редагування (E)", + "Back to Grid": "Назад до сітки", + "Before": "До", + "Before/After (Y)": "До/після (Y)", + "Before/After Settings": "Налаштування до/після", + "Before/After Split": "Розділення до/після", + "Before/After Split Top/Bottom": "Розділення до/після зверху/знизу", + "Before/After Top/Bottom": "До/після зверху/знизу", + "Begin Interaction": "Почати взаємодію", + "Best of Similar Shots": "Найкращі з подібних знімків", + "Bleached": "Вибілене", + "Blue Hour Boost": "Посилення синьої години", + "Blue Hue": "Тон синього", + "Blue Luminance": "Яскравість синього", + "Blue Primary": "Основний синій", + "Blue Saturation": "Насиченість синього", + "Blue channel": "Синій канал", + "Blue label": "Синя мітка", + "Blue/Yellow Fringe": "Синя/жовта облямівка", + "Blurry photos below the score are flagged as rejects.": "Розмиті фото з оцінкою нижче порога позначаються як відхилені.", + "Boundary Warp": "Деформація меж", + "Bright & Airy": "Світле й легке", + "Browse Folder": "Оглянути теку", + "Browse Folder…": "Оглянути теку…", + "Browse Profiles": "Оглянути профілі", + "Brush Stroke": "Мазок пензля", + "Build Previews": "Створити попередні перегляди", + "Build Smart Previews": "Створити смарт-перегляди", + "By Date": "За датою", + "By day (YYYY/YYYY-MM-DD)": "За днем (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "За місяцем (YYYY/YYYY-MM)", + "CPU threads": "Потоки ЦП", + "Calibration": "Калібрування", + "Camera": "Камера", + "Camera make and model": "Виробник і модель камери", + "Cameras and Cards": "Камери й карти", + "Cancel AI Mask Model Download": "Скасувати завантаження моделі масок ШІ", + "Cancel Download": "Скасувати завантаження", + "Cancel Interaction": "Скасувати взаємодію", + "Cancel Preview Build": "Скасувати створення переглядів", + "Candidate": "Кандидат", + "Capture Date": "Дата знімання", + "Capture date and time in your own format (directives below)": "Дата й час знімання у власному форматі (директиви нижче)", + "Capture date as YYYYMMDD": "Дата знімання як YYYYMMDD", + "Captured": "Знято", + "Captured from": "Знято від", + "Captured until": "Знято до", + "Catalog Statistics": "Статистика каталогу", + "Catchlight": "Відблиск в оці", + "Change Component Mode": "Змінити режим компонента", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Недозволені в назвах файлів символи (/ \\ : * ? \" < > |) замінюються на -; до наявної назви додається -1, -2….", + "Check All": "Позначити все", + "Check Output Path": "Перевірити шлях виведення", + "Check the files of this type": "Перевірити файли цього типу", + "Checking names…": "Перевірка назв…", + "Choose Edit Settings to Copy": "Вибрати налаштування редагування для копіювання", + "Choose Edit Settings to Copy…": "Вибрати налаштування редагування для копіювання…", + "Choose Folder…": "Вибрати теку…", + "Choose a photo in the grid or filmstrip": "Виберіть фото в сітці або на стрічці", + "Cinematic": "Кінематографічне", + "Clean Studio": "Чиста студія", + "Clear Cache": "Очистити кеш", + "Clear Filters": "Скинути фільтри", + "Clear Preview Cache": "Очистити кеш переглядів", + "Clear Quick Collection": "Очистити швидку колекцію", + "Clear Reference": "Очистити еталон", + "Clear all": "Очистити все", + "Clear rating": "Скинути оцінку", + "Clear rating and advance": "Скинути оцінку й перейти далі", + "Cleared History": "Історію очищено", + "Click Zoom Ratio": "Масштаб клацання", + "Click a shortcut, then press the new keys (Esc cancels).": "Клацніть скорочення, а потім натисніть нові клавіші (Esc скасовує).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Клацніть тег, щоб вставити його. Приклади для IMG_0042.CR3, знятого 14 січня 2026 о 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Клацайте фото в сітці, щоб перемикати ключове слово; Esc завершує", + "Click the object to select it · ⌥-click leaves a part out": "Клацніть об’єкт, щоб виділити його · ⌥-клацання виключає частину", + "Click the photo to pick a colour · ⇧-click adds more": "Клацніть фото, щоб вибрати колір · ⇧-клацання додає ще", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Клацніть фото, щоб вибрати колір; ⇧-клацання додає ще (до 5)", + "Click to clear these filters": "Клацніть, щоб скинути ці фільтри", + "Click to restore · double-click to rename": "Клацніть, щоб відновити · подвійне клацання перейменовує", + "Close Panel": "Закрити панель", + "Close Window": "Закрити вікно", + "Cloudy": "Хмарно", + "Collapse All Stacks": "Згорнути всі стоси", + "Collapse Stack": "Згорнути стос", + "Color Label": "Кольорова мітка", + "Color Label Names": "Назви кольорових міток", + "Color Label Sets": "Набори кольорових міток", + "Color label cleared": "Кольорову мітку скинуто", + "Color space: sRGB (AVIF)": "Колірний простір: sRGB (AVIF)", + "Command Journal": "Журнал команд", + "Compare": "Порівняти", + "Compare (Shift+C)": "Порівняти (Shift+C)", + "Compare Before and After": "Порівняти до й після", + "Compare needs at least two photos": "Для порівняння потрібно щонайменше два фото", + "Component options": "Параметри компонента", + "Concrete Cool": "Холодний бетон", + "Confirm before deleting": "Підтверджувати перед видаленням", + "Confirm before moving photos to Recently Deleted": "Підтверджувати перед переміщенням фото до нещодавно видалених", + "Constrain Crop": "Обмежити кадрування", + "Continue": "Продовжити", + "Convert to DNG": "Перетворити на DNG", + "Cool Fade": "Холодне згасання", + "Cool Morning": "Холодний ранок", + "Copy After's Settings to Before": "Копіювати налаштування «після» до «до»", + "Copy Before to After": "Копіювати «до» до «після»", + "Copy Before's Settings to After": "Копіювати налаштування «до» до «після»", + "Copy Edit Settings": "Копіювати налаштування редагування", + "Copy History Step Settings to Before": "Копіювати налаштування кроку історії до «до»", + "Copy Metadata": "Копіювати метадані", + "Copy as DNG": "Копіювати як DNG", + "Copy into the library (else use the files where they are)": "Копіювати до бібліотеки (інакше використовувати файли на місці)", + "Copy the files (into the library's Originals/, or a folder you choose)": "Копіювати файли (до Originals/ бібліотеки або вибраної теки)", + "Copy to": "Копіювати до", + "Copy to Clipboard": "Копіювати до буфера обміну", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Копіювати: файли копіюються до теки нижче, а бібліотека використовує копії; оригінали залишаються незмінними.", + "Create Album": "Створити альбом", + "Create Album…": "Створити альбом…", + "Create Folder": "Створити теку", + "Create Folder…": "Створити теку…", + "Create New Mask": "Створити нову маску", + "Create Preset": "Створити набір налаштувань", + "Create Preset…": "Створити набір налаштувань…", + "Create Proof Copy": "Створити пробну копію", + "Create Smart Album": "Створити смарт-альбом", + "Create Smart Album from Filter…": "Створити смарт-альбом із фільтра…", + "Create Smart Album…": "Створити смарт-альбом…", + "Create Stack": "Створити стос", + "Create Version": "Створити версію", + "Create Version from Step": "Створити версію з кроку", + "Create Virtual Copy": "Створити віртуальну копію", + "Creator": "Автор", + "Creator metadata": "Метадані автора", + "Crisp Landscape": "Чіткий пейзаж", + "Crisp Vista": "Чітка панорама", + "Crop & Rotate": "Кадрування й обертання", + "Crop & Rotate (C)": "Кадрування й обертання (C)", + "Crop Aspect": "Пропорції кадрування", + "Crop, Rotate, Geometry": "Кадрування, обертання, геометрія", + "Culling": "Відбір", + "Custom template…": "Власний шаблон…", + "Cycle Crop Overlay": "Перемкнути накладання кадрування", + "Cycle Crop Overlay Orientation": "Перемкнути орієнтацію накладання кадрування", + "Cycle Info Overlay": "Перемкнути накладання інформації", + "Cycle Mask Overlay Color": "Перемкнути колір накладання маски", + "Cycle Mask Overlay Mode": "Перемкнути режим накладання маски", + "Cylindrical": "Циліндрична", + "Dark & Moody": "Темне й похмуре", + "Date": "Дата", + "Date Groups": "Групи дат", + "Day": "День", + "Daylight": "Денне світло", + "Decrease Brush Feather": "Зменшити розтушовування пензля", + "Decrease Brush Size": "Зменшити розмір пензля", + "Deep Sky": "Глибоке небо", + "Deghost Amount": "Ступінь усунення двоїння", + "Delete (⌫)": "Видалити (⌫)", + "Delete Album": "Видалити альбом", + "Delete All Masks": "Видалити всі маски", + "Delete Color Label Set": "Видалити набір кольорових міток", + "Delete Component": "Видалити компонент", + "Delete Export Preset": "Видалити набір налаштувань експорту", + "Delete Filter Preset": "Видалити набір налаштувань фільтра", + "Delete Imported Profile": "Видалити імпортований профіль", + "Delete Keyword": "Видалити ключове слово", + "Delete Keyword Set": "Видалити набір ключових слів", + "Delete Mask": "Видалити маску", + "Delete Metadata Preset": "Видалити набір налаштувань метаданих", + "Delete Permanently": "Видалити назавжди", + "Delete Photo": "Видалити фото", + "Delete Photos": "Видалити фото", + "Delete Point Color": "Видалити точковий колір", + "Delete Point Color Sample": "Видалити зразок точкового кольору", + "Delete Point Curve Preset": "Видалити набір налаштувань точкової кривої", + "Delete Red Eye": "Видалити червоне око", + "Delete Red Eye Correction": "Видалити корекцію червоних очей", + "Delete Spot": "Видалити ділянку", + "Delete Version": "Видалити версію", + "Delete all": "Видалити все", + "Delete all spots": "Видалити всі ділянки", + "Delete mask": "Видалити маску", + "Delete them": "Видалити їх", + "Depth Range": "Діапазон глибини", + "Descending": "За спаданням", + "Describe": "Описати", + "Describe what to keep": "Опишіть, що залишити", + "Describe what to leave out": "Опишіть, що виключити", + "Describe what to select": "Опишіть, що виділити", + "Describe…": "Описати…", + "Desert Heat": "Спека пустелі", + "Desert Warmth": "Тепло пустелі", + "Desktop": "Стільниця", + "Detail (D)": "Деталі (D)", + "Diagonal": "Діагональна", + "Discard Preview Cache": "Видалити кеш переглядів", + "Don't enlarge": "Не збільшувати", + "Download AI Mask Model": "Завантажити модель масок ШІ", + "Download the SAM 3 Model?": "Завантажити модель SAM 3?", + "Downloads": "Завантаження", + "Drag along the horizon; double-click for Auto": "Проведіть уздовж горизонту; подвійне клацання — автоматично", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Проведіть по оку на фото; зіниця всередині знаходиться автоматично.", + "Draw Guides": "Намалювати напрямні", + "Duplicate Component": "Дублювати компонент", + "Duplicate Mask": "Дублювати маску", + "Duplicate and Invert Mask": "Дублювати й інвертувати маску", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Кожен / починає рівень теки; теги заповнюються для кожного фото (рівень без метаданих — \"unknown\").", + "Edit (E)": "Редагування (E)", + "Edit Capture Time": "Редагувати час знімання", + "Edit Capture Time…": "Редагувати час знімання…", + "Edit Color Label Names": "Редагувати назви кольорових міток", + "Edit Color Label Names…": "Редагувати назви кольорових міток…", + "Edit Copy for External Editor": "Редагувати копію в зовнішньому редакторі", + "Edit Date": "Дата редагування", + "Edit Info": "Редагувати інформацію", + "Edit Label Names": "Редагувати назви міток", + "Edit Label Names…": "Редагувати назви міток…", + "Edit Mask": "Редагувати маску", + "Edit Mask Component": "Редагувати компонент маски", + "Edit Sections": "Розділи редагування", + "Edit Smart Album": "Редагувати смарт-альбом", + "Edit Smart Album…": "Редагувати смарт-альбом…", + "Edit Spot": "Редагувати ділянку", + "Edit in External Editor": "Редагувати в зовнішньому редакторі", + "Edit settings copied": "Налаштування редагування скопійовано", + "Edited": "Відредаговано", + "Edited or not": "Відредаговано чи ні", + "Empty Recently Deleted": "Очистити нещодавно видалені", + "Enable Lens Corrections": "Увімкнути корекцію об’єктива", + "Enable Profile Corrections": "Увімкнути корекцію профілю", + "End Interaction": "Завершити взаємодію", + "Enter Full Screen": "Увійти в повноекранний режим", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Введіть дату як YYYY-MM-DD HH:MM:SS", + "Every EXIF, GPS and XMP field in the file": "Усі поля EXIF, GPS і XMP у файлі", + "Existing previews": "Наявні перегляди", + "Expand All Stacks": "Розгорнути всі стоси", + "Expand Stack": "Розгорнути стос", + "Expand/Collapse Stack": "Розгорнути/згорнути стос", + "Export Album with Preset": "Експортувати альбом із набором налаштувань", + "Export Album…": "Експортувати альбом…", + "Export Group…": "Експортувати групу…", + "Export Now": "Експортувати зараз", + "Export Point Curve Presets": "Експортувати набори налаштувань точкової кривої", + "Export Point Curve Presets…": "Експортувати набори налаштувань точкової кривої…", + "Export Presets…": "Експортувати набори налаштувань…", + "Export User Presets…": "Експортувати власні набори налаштувань…", + "Export stopped unexpectedly": "Експорт несподівано зупинився", + "Export with Preset": "Експортувати з набором налаштувань", + "Export with Previous": "Експортувати з попередніми налаштуваннями", + "Exporting": "Експорт", + "Exposure (shutter · aperture · ISO)": "Експозиція (витримка · діафрагма · ISO)", + "Extended Description": "Розширений опис", + "External Editor": "Зовнішній редактор", + "Face Boxes": "Рамки облич", + "Face boxes": "Рамки облич", + "Face names written to XMP by Lightroom and other apps show up here.": "Імена облич, записані до XMP у Lightroom та інших застосунках, з’являються тут.", + "Faded Matte": "Вицвіле матове", + "Faded Slide": "Вицвілий слайд", + "Faded Urban": "Вицвіле місто", + "Favorite Preset": "Додати набір налаштувань до улюблених", + "Favorite Profile": "Додати профіль до улюблених", + "File & date": "Файл і дата", + "File Format": "Формат файлу", + "File Name": "Назва файлу", + "File Path": "Шлях до файлу", + "File Size": "Розмір файлу", + "File Type": "Тип файлу", + "File types:": "Типи файлів:", + "Filename": "Назва файлу", + "Film": "Плівка", + "Filmstrip (/)": "Стрічка (/)", + "Find Missing Photos": "Знайти відсутні фото", + "Five stars": "П’ять зірок", + "Fixed keys": "Фіксовані клавіші", + "Flagged as Pick": "Позначено як відібране", + "Flagged as Reject": "Позначено як відхилене", + "Focus (assisted culling)": "Фокус (допоміжний відбір)", + "Food": "Їжа", + "Forget Unchanged Local Photos": "Забути незмінені локальні фото", + "Frame time": "Час кадру", + "Frame update": "Оновлення кадру", + "Fresh & Bright": "Свіже й яскраве", + "GPU Rendering": "Рендеринг на ГП", + "GPU rendering": "Рендеринг на ГП", + "Generated demo photo": "Згенероване демонстраційне фото", + "Get Develop Settings": "Отримати налаштування обробки", + "Go to History Step": "Перейти до кроку історії", + "Golden Glow": "Золотисте сяйво", + "Golden Hour": "Золота година", + "Green label": "Зелена мітка", + "Gritty Street": "Груба вулиця", + "Group by Date: Automatic": "Групування за датою: автоматично", + "Guided Upright Guides": "Напрямні керованого випрямлення", + "HDR Merge Preview": "Попередній перегляд об’єднання HDR", + "HDR Panorama Merge Preview": "Попередній перегляд об’єднання HDR-панорами", + "HDR Panorama with Last Settings": "HDR-панорама з останніми налаштуваннями", + "HDR Panoramas": "HDR-панорами", + "HDR panorama": "HDR-панорама", + "HDR panoramas": "HDR-панорами", + "HDR with Last Settings": "HDR з останніми налаштуваннями", + "Has Edits": "Має зміни", + "Has GPS": "Має GPS", + "High Contrast B&W": "Контрастне чорно-біле", + "Hours": "Години", + "ISO speed": "Чутливість ISO", + "Import Lightroom Catalog": "Імпортувати каталог Lightroom", + "Import Lightroom Catalog…": "Імпортувати каталог Lightroom…", + "Import Point Curve Presets…": "Імпортувати набори налаштувань точкової кривої…", + "Import Profiles": "Імпортувати профілі", + "Import as new": "Імпортувати як нове", + "Importing Lightroom Catalog": "Імпорт каталогу Lightroom", + "In Photo Grid and Square Grid: Flag as Pick and advance": "У сітці фото й квадратній сітці: позначити як відібране й перейти далі", + "In Photo Grid and Square Grid: expand or collapse the stack": "У сітці фото й квадратній сітці: розгорнути або згорнути стос", + "Increase Brush Feather": "Збільшити розтушовування пензля", + "Increase Brush Size": "Збільшити розмір пензля", + "Info Overlay Off": "Вимкнути накладання інформації", + "Info Overlay: Exposure": "Накладання інформації: експозиція", + "Info Overlay: File": "Накладання інформації: файл", + "Insert at the cursor": "Вставити в позицію курсора", + "Inspect Lightroom Catalog": "Перевірити каталог Lightroom", + "Inspect Photo": "Перевірити фото", + "Inspecting Lightroom Catalog": "Перевірка каталогу Lightroom", + "Instant ’70s": "Миттєві 70-ті", + "Invert Component": "Інвертувати компонент", + "JPEG (Large)": "JPEG (великий)", + "JPEG (Small)": "JPEG (малий)", + "Join the ArtCraft community on Discord": "Приєднатися до спільноти ArtCraft у Discord", + "Keywords (K)": "Ключові слова (K)", + "Label Set": "Набір міток", + "Last GPU fallback": "Останній перехід із ГП", + "Last loupe render": "Останній рендеринг лупи", + "Library Info": "Інформація про бібліотеку", + "Library Preferences": "Налаштування бібліотеки", + "Library State": "Стан бібліотеки", + "Library saved": "Бібліотеку збережено", + "Licence:": "Ліцензія:", + "LightCraft could not start": "Не вдалося запустити LightCraft", + "Lightroom Classic Catalog": "Каталог Lightroom Classic", + "Lightroom import cancelled": "Імпорт Lightroom скасовано", + "Lightroom inspection cancelled": "Перевірку Lightroom скасовано", + "List Albums": "Список альбомів", + "List Develop Controls": "Список елементів обробки", + "List History": "Список історії", + "List Presets": "Список наборів налаштувань", + "List Profiles": "Список профілів", + "Locate Photo": "Знайти фото", + "Lock Crop Aspect": "Зафіксувати пропорції кадрування", + "Locked": "Заблоковано", + "Lush Greens": "Пишна зелень", + "Mask Adjustment": "Корекція маски", + "Match Total Exposures": "Узгодити загальну експозицію", + "Matte Soft": "М’яке матове", + "Medium Contrast": "Середній контраст", + "Memory Budget": "Ліміт пам’яті", + "Memory Usage": "Використання пам’яті", + "Memory budget": "Ліміт пам’яті", + "Merged": "Об’єднано", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Умови Meta, а не LightCraft. Завантажуючи модель, ви приймаєте їх.", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Відсутні метадані ({camera}, {title}…) залишають порожнє місце; порожня отримана назва зберігає початкову.", + "Misty Morning": "Туманний ранок", + "Mono Red Filter": "Монохромний червоний фільтр", + "Mono Rich": "Насичений монохром", + "Mono Soft": "М’який монохром", + "Moody": "Похмуре", + "Moody Portrait": "Похмурий портрет", + "Move Album": "Перемістити альбом", + "Move Folder": "Перемістити теку", + "Move Mask": "Перемістити маску", + "Move Preset to Group": "Перемістити набір налаштувань до групи", + "Move Preset to New Group": "Перемістити набір налаштувань до нової групи", + "Move in Stack": "Перемістити в стосі", + "Muted": "Приглушене", + "Muted Film": "Приглушена плівка", + "My Photos Panel": "Панель моїх фото", + "Name of the folder the original is in": "Назва теки з оригіналом", + "Named People": "Люди з іменами", + "Neon Dusk": "Неонові сутінки", + "Neon Night": "Неонова ніч", + "Nested group": "Вкладена група", + "New Album": "Новий альбом", + "New time": "Новий час", + "Next Photo": "Наступне фото", + "Night Blue": "Нічна синява", + "No named people in these photos": "На цих фото немає людей з іменами", + "No named people yet": "Поки немає людей з іменами", + "Not Now": "Не зараз", + "Nudge Develop Value": "Змінити значення обробки на крок", + "Object Mask": "Маска об’єкта", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Маски «Об’єкт» і «Описати» використовують SAM 3, модель сегментації Meta. Вона не є частиною LightCraft, а решта функцій працює без неї.", + "October 2026": "Жовтень 2026", + "Only": "Лише", + "Optimize Library": "Оптимізувати бібліотеку", + "Original + Settings": "Оригінал + налаштування", + "Original extension without the dot": "Початкове розширення без крапки", + "Original file name without its extension": "Початкова назва файлу без розширення", + "Panorama": "Панорама", + "Panorama Merge Preview": "Попередній перегляд об’єднання панорами", + "Panorama with Last Settings": "Панорама з останніми налаштуваннями", + "Panoramas": "Панорами", + "Paste Settings": "Вставити налаштування", + "Paste Settings from Previous": "Вставити налаштування з попереднього", + "Paste from Previous": "Вставити з попереднього", + "Pastel Haze": "Пастельний серпанок", + "People": "Люди", + "Person": "Особа", + "Pet Eye Catchlight": "Відблиск в оці тварини", + "Photos per bracket (0 = auto)": "Фото в серії експозицій (0 = автоматично)", + "Pick Flag": "Прапорець відбору", + "Pictures": "Зображення", + "Prepare AI Masks": "Підготувати маски ШІ", + "Presets and profiles": "Набори налаштувань і профілі", + "Preview Build Progress": "Перебіг створення переглядів", + "Preview only: editing the camera's embedded JPEG": "Лише перегляд: редагування вбудованого JPEG камери", + "Previous Photo": "Попереднє фото", + "Profile Browser": "Оглядач профілів", + "Profile Menu": "Меню профілів", + "Proof copy created": "Пробну копію створено", + "Query Photos": "Запит фото", + "Quiet Green": "Спокійна зелень", + "Quit Anyway": "Усе одно вийти", + "Quit with unsaved changes?": "Вийти з незбереженими змінами?", + "RAW decoding": "Декодування RAW", + "Rate ★": "Оцінити ★", + "Rate ★ and advance": "Оцінити ★ і перейти далі", + "Rate ★★": "Оцінити ★★", + "Rate ★★ and advance": "Оцінити ★★ і перейти далі", + "Rate ★★★": "Оцінити ★★★", + "Rate ★★★ and advance": "Оцінити ★★★ і перейти далі", + "Rate ★★★★": "Оцінити ★★★★", + "Rate ★★★★ and advance": "Оцінити ★★★★ і перейти далі", + "Rate ★★★★★": "Оцінити ★★★★★", + "Rate ★★★★★ and advance": "Оцінити ★★★★★ і перейти далі", + "Rating cleared": "Оцінку скинуто", + "Raw": "RAW", + "Read the SAM License": "Прочитати ліцензію SAM", + "Reading Lightroom catalog…": "Читання каталогу Lightroom…", + "Recent Keywords": "Останні ключові слова", + "Red label": "Червона мітка", + "Refine": "Уточнити", + "Refine AI Mask Detail": "Уточнити деталі маски ШІ", + "Refine Edges": "Уточнити краї", + "Refine Mask Edges": "Уточнити краї маски", + "Refine Saturation": "Уточнити насиченість", + "Refining the mask's detail…": "Уточнення деталей маски…", + "Refresh Source": "Оновити джерело", + "Reject": "Відхилити", + "Reliability": "Надійність", + "Relink Photo": "Повторно пов’язати фото", + "Reload Changed Files": "Перезавантажити змінені файли", + "Remove Face Box": "Видалити рамку обличчя", + "Remove a filter above, or choose Clear all": "Видаліть фільтр вище або виберіть «Очистити все»", + "Remove from Target Album": "Вилучити з цільового альбому", + "Remove shortcut": "Видалити скорочення", + "Remove this face box (undo with Edit ▸ Undo)": "Видалити цю рамку обличчя (скасувати через Редагування → Скасувати)", + "Rename Component": "Перейменувати компонент", + "Rename Folder": "Перейменувати теку", + "Rename Mask": "Перейменувати маску", + "Rename Preset": "Перейменувати набір налаштувань", + "Rename Preview": "Попередній перегляд перейменування", + "Rename Template Tags": "Теги шаблону перейменування", + "Reorder Masks": "Змінити порядок масок", + "Reset All": "Скинути все", + "Reset All Keyboard Shortcuts": "Скинути всі клавіатурні скорочення", + "Reshuffle": "Перемішати", + "File name": "Назва файлу", + "Fill Edges": "Заповнити краї", + "Filmstrip": "Стрічка", + "Filter Bar": "Панель фільтрів", + "Filter Presets": "Набори налаштувань фільтрів", + "Filter bar": "Панель фільтрів", + "Filter fields": "Поля фільтрів", + "Find Dust Spots": "Знайти пилові плями", + "Find Missing Photos…": "Знайти відсутні фото…", + "Find Similar Photos": "Знайти подібні фото", + "Fit (F)": "Умістити (F)", + "Flag as Pick": "Позначити як відібране", + "Flag as Reject": "Позначити як відхилене", + "Flip H": "Віддзеркалити горизонтально", + "Flip Overlay (⇧O)": "Перевернути накладання (⇧O)", + "Flip V": "Віддзеркалити вертикально", + "Fluorescent": "Люмінесцентне", + "Focal length": "Фокусна відстань", + "Folder name (renamed on disk; its photos follow)": "Назва теки (перейменовується на диску разом із посиланнями на фото)", + "Folders": "Теки", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Теки й {date} використовують час знімання; фото без нього використовує сьогоднішню дату.", + "Forget unchanged photos": "Забути незмінені фото", + "Forward": "Уперед", + "Full Screen Preview": "Повноекранний перегляд", + "Full size": "Повний розмір", + "GPS Track Log": "Журнал маршруту GPS", + "Geometry": "Геометрія", + "Global Hue": "Загальний тон", + "Global Luminance": "Загальна яскравість", + "Global Saturation": "Загальна насиченість", + "Golden": "Золотисте", + "Green Amount": "Кількість зеленого", + "Green Hue": "Тон зеленого", + "Green Hue High": "Верхня межа тону зеленого", + "Green Hue Low": "Нижня межа тону зеленого", + "Green Luminance": "Яскравість зеленого", + "Green Primary": "Основний зелений", + "Green Saturation": "Насиченість зеленого", + "Green channel": "Зелений канал", + "Grid Info": "Інформація в сітці", + "Group by Day": "Групувати за днем", + "Group by Month": "Групувати за місяцем", + "Group by Year": "Групувати за роком", + "Group by date": "Групувати за датою", + "Group into Stack": "Об’єднати в стос", + "Guided": "Кероване", + "Guided Upright": "Кероване випрямлення", + "HDR Merge": "Об’єднання HDR", + "HDR Panorama Merge": "Об’єднання HDR-панорами", + "HDR Panorama…": "HDR-панорама…", + "HDR…": "HDR…", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Усунути дрібні м’які темні плями, які пил на сенсорі залишає на однорідних ділянках", + "Height (px)": "Висота (пікселів)", + "Hide LightCraft": "Приховати LightCraft", + "Hide Mask": "Приховати маску", + "Hides this shortcut only; the folder and its photos stay as they are": "Приховує лише це скорочення; тека та її фото залишаються незмінними", + "Highlights Hue": "Тон світлих ділянок", + "Highlights Luminance": "Яскравість світлих ділянок", + "Highlights Saturation": "Насиченість світлих ділянок", + "Highlights split": "Розділення світлих ділянок", + "History & Activity (Y)": "Історія й дії (Y)", + "Hue Range": "Діапазон тону", + "If file exists": "Якщо файл існує", + "Import Date": "Дата імпорту", + "Import Photos": "Імпортувати фото", + "Import Photos…": "Імпортувати фото…", + "Import Point Curve Presets": "Імпортувати набори налаштувань точкової кривої", + "Import Presets…": "Імпортувати набори налаштувань…", + "Import Profiles & Presets…": "Імпортувати профілі й набори налаштувань…", + "Import from Device": "Імпортувати з пристрою", + "Import from Folder…": "Імпортувати з теки…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Імпортуйте фото через Файл → Імпортувати фото… (Cmd+Shift+I) або перетягніть їх сюди", + "Imported Photos": "Імпортовані фото", + "Importing": "Імпорт", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "Імпорт сканує джерело фото; він не додає теку до локальних (використовуйте Локальні → Оглянути теку…, щоб працювати в теці без імпорту).", + "In library": "У бібліотеці", + "In memory — nothing is saved": "У пам’яті — нічого не збережено", + "In use": "Використовується", + "In-memory session: these settings last until LightCraft quits.": "Сеанс у пам’яті: ці налаштування діють до виходу з LightCraft.", + "Include subfolders": "Включати вкладені теки", + "Info (I)": "Інформація (I)", + "Info overlay": "Накладання інформації", + "Into one folder": "До однієї теки", + "Keep in Local": "Залишити в локальних", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Зберігайте на диску з вільним місцем: смарт-перегляд займає приблизно 1 МБ на фото. Кеш мініатюр залишається в бібліотеці.", + "Keyboard shortcuts": "Клавіатурні скорочення", + "Keyword": "Ключове слово", + "Keyword Painter": "Призначення ключових слів", + "Keyword Set": "Набір ключових слів", + "Keyword Sets": "Набори ключових слів", + "Keyword Suggestions": "Пропозиції ключових слів", + "Keyword to paint": "Ключове слово для призначення", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Додані ключові слова з’являються тут; ⌥1–⌥9 застосовують їх.", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Автоматичний режим відтворює подання «Деталі» у відображуваному розмірі, до власних пікселів фото. Виберіть розмір для обмеження: менший працює швидше.", + "Last view": "Останнє подання", + "Lateral Chromatic Aberration": "Поперечна хроматична аберація", + "Leave them": "Залишити їх", + "Library Originals": "Оригінали бібліотеки", + "LightCraft Default": "Типові налаштування LightCraft", + "LightCraft Help": "Довідка LightCraft", + "LightCraft Preset": "Набір налаштувань LightCraft", + "LightCraft Presets": "Набори налаштувань LightCraft", + "LightCraft Website": "Сайт LightCraft", + "LightCraft on GitHub": "LightCraft на GitHub", + "LightCraft website": "Сайт LightCraft", + "LightCraft — Second Window": "LightCraft — друге вікно", + "Limit file size (KB, 0 = off)": "Обмежити розмір файлу (КБ, 0 = вимкнено)", + "List this folder in Local from now on": "Відтепер показувати цю теку в локальних", + "Local": "Локальні", + "Local folders": "Локальні теки", + "Local library — no cloud account needed": "Локальна бібліотека — хмарний обліковий запис не потрібен", + "Locate Missing File…": "Знайти відсутній файл…", + "Long Edge": "Довгий край", + "Looking for photos…": "Пошук фото…", + "Luminance Range": "Діапазон яскравості", + "Magenta Hue": "Тон пурпурового", + "Magenta Luminance": "Яскравість пурпурового", + "Magenta Saturation": "Насиченість пурпурового", + "Make Candidate the Select": "Зробити кандидата вибраним", + "Manual": "Вручну", + "Masking (M)": "Маскування (M)", + "Masks": "Маски", + "Match": "Збіг", + "Med": "Середнє", + "Megapixels": "Мегапікселі", + "Memory for caches": "Пам’ять для кешування", + "Menu": "Меню", + "Merge Keywords": "Об’єднати ключові слова", + "Merge into…": "Об’єднати з…", + "Merging…": "Об’єднання…", + "Metadata Preset": "Набір налаштувань метаданих", + "Metadata Presets": "Набори налаштувань метаданих", + "Metadata preset": "Набір налаштувань метаданих", + "Midtones Hue": "Тон середніх тонів", + "Midtones Luminance": "Яскравість середніх тонів", + "Midtones Saturation": "Насиченість середніх тонів", + "Midtones split": "Розділення середніх тонів", + "Missing Photos": "Відсутні фото", + "Modified Date": "Дата зміни", + "Month": "Місяць", + "More preset options": "Інші параметри набору налаштувань", + "Move Down in Stack": "Перемістити нижче в стосі", + "Move Folder To…": "Перемістити теку до…", + "Move Up in Stack": "Перемістити вище в стосі", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Перемістити файли (до Originals/ бібліотеки або вибраної теки), видаливши їх із джерела", + "Move them": "Перемістити їх", + "Move to Group": "Перемістити до групи", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Перемістити: файли переміщуються до теки нижче; кожен оригінал (і його супровідний XMP) видаляється з джерела лише після перевірки копії. Дублікати й файли з помилками залишаються на місці.", + "My Photos": "Мої фото", + "Named": "З іменами", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Назви з’являються в меню міток, панелі фільтрів і панелі інформації та записуються до XMP. Порожнє значення = назва кольору.", + "Needs Work": "Потребує роботи", + "Neutral": "Нейтральне", + "New Album…": "Новий альбом…", + "New Folder…": "Нова тека…", + "New Group…": "Нова група…", + "New Smart Album": "Новий смарт-альбом", + "New Smart Album from Filter…": "Новий смарт-альбом із фільтра…", + "New Smart Album…": "Новий смарт-альбом…", + "New album": "Новий альбом", + "New album…": "Новий альбом…", + "New name": "Нова назва", + "No Camera or Card Found": "Камеру або карту не знайдено", + "No Date Groups": "Без групування за датою", + "No Stars": "Без зірок", + "No Tool": "Без інструмента", + "No automatic versions.": "Немає автоматичних версій.", + "No camera": "Без камери", + "No date": "Без дати", + "No dust spots found": "Пилових плям не знайдено", + "No edits yet.": "Змін поки немає.", + "No exposure information": "Немає інформації про експозицію", + "No filter presets yet": "Наборів налаштувань фільтрів поки немає", + "No lens data embedded in this file (DNG lens opcodes only).": "У цьому файлі немає даних об’єктива (лише інструкції об’єктива DNG).", + "No masks yet. Choose a mask type above.": "Масок поки немає. Виберіть тип маски вище.", + "No matching photos": "Немає відповідних фото", + "No metadata found": "Метаданих не знайдено", + "No new presets": "Немає нових наборів налаштувань", + "No photo selected": "Фото не вибрано", + "No photos": "Немає фото", + "No photos found": "Фото не знайдено", + "No photos in this folder": "У цій теці немає фото", + "No photos match the active filters": "Жодне фото не відповідає активним фільтрам", + "No photos match the filters (View → Clear Filters)": "Жодне фото не відповідає фільтрам (Вигляд → Скинути фільтри)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Фото RAW поки немає: камери з’являться тут, коли їхні фото будуть у бібліотеці.", + "No reference photo": "Немає еталонного фото", + "No usable GPU found: rendering on the CPU": "Придатного ГП не знайдено: рендеринг на ЦП", + "No versions yet. Create one to keep this look.": "Версій поки немає. Створіть версію, щоб зберегти цей вигляд.", + "Non-raw photos": "Фото не у форматі RAW", + "Nothing to compare": "Нічого порівнювати", + "Nothing to survey": "Нічого оглядати", + "Offset Y": "Зсув Y", + "Opacity (%)": "Непрозорість (%)", + "Open Library": "Відкрити бібліотеку", + "Open Library…": "Відкрити бібліотеку…", + "Open in": "Відкрити в", + "Open in Detail": "Відкрити в деталях", + "Open the place in OpenStreetMap": "Відкрити місце в OpenStreetMap", + "Optics": "Оптика", + "Optional name": "Необов’язкова назва", + "Orange Hue": "Тон оранжевого", + "Orange Luminance": "Яскравість оранжевого", + "Orange Saturation": "Насиченість оранжевого", + "Original files": "Початкові файли", + "Original offline · editing the smart preview": "Оригінал недоступний · редагування смарт-перегляду", + "Original offline · no smart preview": "Оригінал недоступний · немає смарт-перегляду", + "Originals": "Оригінали", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Інші зображення (JPEG, PNG, TIFF, HEIC…)", + "Paint": "Малювати", + "Paint only areas like the one under the brush": "Малювати лише ділянки, подібні до ділянки під пензлем", + "Paint over a distraction on the photo to remove it.": "Замалюйте зайвий об’єкт на фото, щоб видалити його.", + "Paint overlay": "Накладання малювання", + "Panorama Merge": "Об’єднання панорами", + "Panorama…": "Панорама…", + "Parametric curve": "Параметрична крива", + "Paste Edit Settings": "Вставити налаштування редагування", + "Paste Metadata": "Вставити метадані", + "Paste Selected Settings": "Вставити вибрані налаштування", + "Paste Selected Settings…": "Вставити вибрані налаштування…", + "Percentage": "Відсоток", + "Pet Eye": "Око тварини", + "Photo Grid": "Сітка фото", + "Photo Grid (G)": "Сітка фото (G)", + "Photo Merge": "Об’єднання фото", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Фото → Редагувати в зовнішньому редакторі (⇧⌘E) створює 16-бітну копію TIFF, додає її до стосу з оригіналом і відкриває тут (назва застосунку в macOS, шлях до програми в інших системах).", + "Photos": "Фото", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Фото, переглянуті в локальних, але ніколи не додані й не змінені, вилучаються з каталогу, якщо їхню теку не оглядали протягом цього часу (перевіряється під час відкриття бібліотеки). Файли залишаються на диску; огляд теки знову їх показує.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Фото, що надходять до цієї теки (камерне підключення, сканер, застосунок синхронізації), додаються одразу після завершення запису.", + "Pick": "Відібрати", + "Pick a colour on the photo with the eyedropper.": "Виберіть колір на фото піпеткою.", + "Pick the sharpest photo of each burst": "Відібрати найчіткіше фото кожної серії", + "Picked": "Відібрано", + "Picks": "Відібрані", + "Point Color": "Точковий колір", + "Point Curve Presets": "Набори налаштувань точкової кривої", + "Point curve": "Точкова крива", + "Preset name": "Назва набору налаштувань", + "Presets & Profiles": "Набори налаштувань і профілі", + "Presets (Shift+P)": "Набори налаштувань (Shift+P)", + "Presets ▾": "Набори налаштувань ⌄", + "Preview only": "Лише перегляд", + "Preview size": "Розмір перегляду", + "Previews": "Перегляди", + "Profile Distortion": "Профіль дисторсії", + "Profile Vignetting": "Профіль віньєтування", + "Profiles": "Профілі", + "Projection": "Проєкція", + "Public domain": "Суспільне надбання", + "Purple Amount": "Кількість фіолетового", + "Purple Hue": "Тон фіолетового", + "Purple Hue High": "Верхня межа тону фіолетового", + "Purple Hue Low": "Нижня межа тону фіолетового", + "Purple Luminance": "Яскравість фіолетового", + "Purple Saturation": "Насиченість фіолетового", + "Put the locations you removed from Local back (no files change)": "Повернути вилучені з локальних розташування (файли не змінюються)", + "Quick Collection": "Швидка колекція", + "Quick Develop": "Швидка обробка", + "Quit LightCraft": "Вийти з LightCraft", + "Rating": "Оцінка", + "Ratings & flags": "Оцінки й прапорці", + "Raw default": "Типові налаштування RAW", + "Raw defaults": "Типові налаштування RAW", + "Raw files": "Файли RAW", + "Raw photos": "Фото RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Фото RAW як DNG із вбудованими змінами. Параметри розміру, кольору й виведення не застосовуються.", + "Read Metadata from File": "Прочитати метадані з файлу", + "Reading folder…": "Читання теки…", + "Recently Added": "Нещодавно додані", + "Recently Deleted": "Нещодавно видалені", + "Red Hue": "Тон червоного", + "Red Luminance": "Яскравість червоного", + "Red Primary": "Основний червоний", + "Red Saturation": "Насиченість червоного", + "Red channel": "Червоний канал", + "Red/Cyan Fringe": "Червона/блакитна облямівка", + "Reference": "Еталон", + "Reference View": "Еталонне подання", + "Reference the files where they are": "Посилатися на файли в їхньому розташуванні", + "Refresh Source (/)": "Оновити джерело (/)", + "Reject below focus": "Відхиляти нижче порога фокуса", + "Rejected": "Відхилено", + "Rejects": "Відхилені", + "Remove (H)": "Видалення (H)", + "Remove CA": "Усунути хроматичну аберацію", + "Remove Chromatic Aberration": "Усунути хроматичну аберацію", + "Camera local tone mapping": "Локальне тональне відображення камери", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Застосувати локальне тональне відображення, збережене в RAW (Apple ProRAW), як його відтворює камера", + "Remove from Album": "Вилучити з альбому", + "Remove from Favorites": "Вилучити з улюблених", + "Remove from Local": "Вилучити з локальних", + "Remove from Photo": "Вилучити з фото", + "Remove from Selected Photos": "Вилучити з вибраних фото", + "Remove from Stack": "Вилучити зі стосу", + "Remove from survey": "Вилучити з огляду", + "Remove location info": "Видалити інформацію про розташування", + "Remove this filter": "Видалити цей фільтр", + "Remove this group": "Видалити цю групу", + "Remove this rule": "Видалити це правило", + "Rename Album": "Перейменувати альбом", + "Rename Folder…": "Перейменувати теку…", + "Rename Keyword": "Перейменувати ключове слово", + "Rename Keyword…": "Перейменувати ключове слово…", + "Rename Photos": "Перейменувати фото", + "Rename Photos…": "Перейменувати фото…", + "Rename Version": "Перейменувати версію", + "Rendering": "Рендеринг", + "Rendering…": "Рендеринг…", + "Reset All Curves": "Скинути всі криві", + "Reset Before to Import State": "Скинути «до» до стану імпорту", + "Reset Control": "Скинути елемент керування", + "Reset Crop": "Скинути кадрування", + "Reset Edits": "Скинути зміни", + "Reset Parametric Curve": "Скинути параметричну криву", + "Reset Tone Curve": "Скинути тональну криву", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Скинути всі криві: точкові криві (усі канали) і параметричну (подвійне клацання каналу скидає лише його)", + "Reset all edits (Cmd+Shift+R)": "Скинути всі зміни (Cmd+Shift+R)", + "Resize Face Box": "Змінити розмір рамки обличчя", + "Resize to": "Змінити розмір до", + "Resolution (ppi)": "Роздільність (ppi)", + "Restore (no shortcut)": "Відновити (без скорочення)", + "Restore Version": "Відновити версію", + "Restore keeps their edits; Import as new deletes the old record first.": "Відновлення зберігає зміни; імпорт як нове спочатку видаляє старий запис.", + "Restore them": "Відновити їх", + "Retouch": "Ретушування", + "Review Import": "Перевірити імпорт", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Клацніть фото правою кнопкою → Задати як еталонне фото, потім Вигляд → Еталонне подання (Shift+R)", + "Right-click a preset to delete it": "Клацніть набір налаштувань правою кнопкою, щоб видалити його", + "Rights Usage Terms": "Умови використання прав", + "Rotate Crop": "Обертати кадрування", + "Rotate Crop Aspect": "Повернути пропорції кадрування", + "Rotate Left": "Повернути ліворуч", + "Rotate Right": "Повернути праворуч", + "Rules": "Правила", + "SAM 3 download stopped: it resumes where it left off next time.": "Завантаження SAM 3 зупинено: наступного разу воно продовжиться з того самого місця.", + "Same as raw default": "Як у типових налаштуваннях RAW", + "Sample Mask Color": "Взяти зразок кольору маски", + "Sample a colour on the photo": "Взяти зразок кольору на фото", + "Saturation Range": "Діапазон насиченості", + "Save Color Label Set": "Зберегти набір кольорових міток", + "Save Current Filter as Preset…": "Зберегти поточний фільтр як набір налаштувань…", + "Save Current Keywords as Set…": "Зберегти поточні ключові слова як набір…", + "Save Export Preset": "Зберегти набір налаштувань експорту", + "Save Filter Preset": "Зберегти набір налаштувань фільтра", + "Save Keyword Set": "Зберегти набір ключових слів", + "Save Metadata Preset": "Зберегти набір налаштувань метаданих", + "Save Metadata Preset…": "Зберегти набір налаштувань метаданих…", + "Save Metadata to File": "Зберегти метадані до файлу", + "Save Point Curve Preset": "Зберегти набір налаштувань точкової кривої", + "Save Point Curve…": "Зберегти точкову криву…", + "Save as Preset": "Зберегти як набір налаштувань", + "Save as Set…": "Зберегти як набір…", + "Save as Smart Album…": "Зберегти як смарт-альбом…", + "Save as set": "Зберегти як набір", + "Save preset": "Зберегти набір налаштувань", + "Saving Lightroom import index…": "Збереження індексу імпорту Lightroom…", + "Scan failed": "Сканування не вдалося", + "Scanning": "Сканування", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Оцінки залишаються на фото: фільтруйте або створюйте смарт-альбоми за фокусом і найкращими з подібних знімків.", + "Scroll menu down": "Прокрутити меню вниз", + "Scroll menu up": "Прокрутити меню вгору", + "Search Photos": "Пошук фото", + "Search commands or keys": "Пошук команд або клавіш", + "Seasons": "Пори року", + "Second Window": "Друге вікно", + "Select Luminance Range": "Вибрати діапазон яскравості", + "Select Mask": "Вибрати маску", + "Select Photos": "Вибрати фото", + "Select Spot": "Вибрати ділянку", + "Select a photo to edit": "Виберіть фото для редагування", + "Select by": "Вибрати за", + "Select by Flag, Rating or Label": "Вибрати за прапорцем, оцінкою або міткою", + "Select photos, then choose View → Survey (N)": "Виберіть фото, а потім Вигляд → Огляд (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Виберіть два фото, а потім Вигляд → Порівняти (Shift+C)", + "Selected": "Вибрано", + "Selecting…": "Виділення…", + "Selenium Tone": "Селенове тонування", + "Send Feedback…": "Надіслати відгук…", + "Sepia Tone": "Тон сепії", + "Sequence number zero-padded to N digits (1–9)": "Порядковий номер із нулями до N цифр (1–9)", + "Sequence number, counting from the start number": "Порядковий номер, починаючи з початкового числа", + "Set": "Задати", + "Set Album Cover": "Задати обкладинку альбому", + "Set Before": "Задати «до»", + "Set Color Label": "Задати кольорову мітку", + "Set Crop": "Задати кадрування", + "Set Develop Values": "Задати значення обробки", + "Set Flag": "Задати прапорець", + "Set Image Zoom and Pan": "Задати масштаб і панорамування зображення", + "Set Mask Adjustments": "Задати корекції маски", + "Set Point Curve": "Задати точкову криву", + "Set Profile": "Задати профіль", + "Set Rating": "Задати оцінку", + "Set Top of Stack": "Задати верхнє фото стосу", + "Set as Album Cover": "Задати як обкладинку альбому", + "Set as Before": "Задати як «до»", + "Set as Reference Photo": "Задати як еталонне фото", + "Set as Target Album": "Задати як цільовий альбом", + "Set as Target Album (B adds to it)": "Задати як цільовий альбом (B додає до нього)", + "Set as Top of Stack": "Задати як верхнє фото стосу", + "Set date & time": "Задати дату й час", + "Set name": "Назва набору", + "Set the mix from the photo's colours": "Задати змішування за кольорами фото", + "Settings file problem": "Проблема файлу налаштувань", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Початкові налаштування нових фото RAW. Їхня зміна не впливає на фото, які вже є в бібліотеці.", + "Settings pasted": "Налаштування вставлено", + "Settings to include": "Налаштування для включення", + "Shade": "Тінь", + "Shadows Hue": "Тон тіней", + "Shadows Luminance": "Яскравість тіней", + "Shadows Saturation": "Насиченість тіней", + "Shadows Tint": "Відтінок тіней", + "Shadows split": "Розділення тіней", + "Shift by": "Зсунути на", + "Short Edge": "Короткий край", + "Show Clipping": "Показати відсікання", + "Show Deghost Overlay": "Показати накладання усунення двоїння", + "Show Hidden Local Locations": "Показати приховані локальні розташування", + "Show Luminance Map": "Показати карту яскравості", + "Show Mask Overlay": "Показати накладання маски", + "Show Mask Pins": "Показати маркери масок", + "Show Original": "Показати оригінал", + "Show Photo Counts": "Показати кількість фото", + "Show Photos with Keyword": "Показати фото з ключовим словом", + "Show Pins": "Показати маркери", + "Show Source": "Показати джерело", + "Show commands without a shortcut": "Показати команди без скорочення", + "Show destination gamut warning": "Показати попередження про гаму призначення", + "Show display gamut warning": "Показати попередження про гаму дисплея", + "Show file names": "Показати назви файлів", + "Show in Explorer": "Показати в Провіднику", + "Show in File Manager": "Показати у файловому менеджері", + "Show in Finder": "Показати у Finder", + "Show on Map": "Показати на карті", + "Show overlay (O)": "Показати накладання (O)", + "Show ratings, flags and edit badges": "Показати оцінки, прапорці й позначки змін", + "Show the Navigator while zoomed in": "Показувати навігатор під час збільшення", + "Show the template tags; click one to insert it": "Показати теги шаблону; клацніть тег, щоб вставити його", + "Show/Hide Mask": "Показати/приховати маску", + "Show/hide My Photos panel": "Показати/приховати панель моїх фото", + "Shutter speed": "Витримка", + "Sidecar names": "Назви супровідних файлів", + "Size (% of short edge)": "Розмір (% короткого краю)", + "Size limit": "Обмеження розміру", + "Skip": "Пропустити", + "Slideshow": "Показ слайдів", + "Slideshow paused": "Показ слайдів призупинено", + "Slideshow resumed": "Показ слайдів відновлено", + "Slideshow · Space pauses · Esc ends": "Показ слайдів · Space призупиняє · Esc завершує", + "Smart Album": "Смарт-альбом", + "Smart Album Rule Fields": "Поля правил смарт-альбому", + "Smart Preview Status": "Стан смарт-перегляду", + "Smart Previews Location": "Розташування смарт-переглядів", + "Smart preview available": "Смарт-перегляд доступний", + "Smart previews": "Смарт-перегляди", + "Smart previews folder changed": "Теку смарт-переглядів змінено", + "Soft B&W": "М’яке чорно-біле", + "Soft Pastel": "М’яка пастель", + "Soft Proofing": "Екранна кольоропроба", + "Soft Skin": "М’яка шкіра", + "Sort by": "Сортувати за", + "Spherical": "Сферична", + "Split Stack": "Розділити стос", + "Spring Fresh": "Весняна свіжість", + "Square Grid": "Квадратна сітка", + "Square Grid (G toggles)": "Квадратна сітка (G перемикає)", + "Square Grid: show file names and formats": "Квадратна сітка: показувати назви й формати файлів", + "Stack": "Стос", + "Stack photos taken within this time of each other:": "Об’єднати в стос фото, зняті з такою різницею в часі:", + "Stacks": "Стоси", + "Star rating (0–5)": "Оцінка зірками (0–5)", + "Start at": "Почати з", + "Start number": "Початкове число", + "Starting": "Запуск", + "Startup": "Запуск", + "State / Province": "Область / провінція", + "Stop Building Previews": "Зупинити створення переглядів", + "Stop Using as Target Album": "Не використовувати як цільовий альбом", + "Stopping…": "Зупинення…", + "Straighten Tool": "Інструмент випрямлення", + "Strong Contrast": "Сильний контраст", + "Subfolder": "Вкладена тека", + "Subfolder name": "Назва вкладеної теки", + "Subtract from this mask": "Відняти від цієї маски", + "Suggestions": "Пропозиції", + "Summer Haze": "Літній серпанок", + "Survey": "Огляд", + "Survey (N)": "Огляд (N)", + "Swap": "Поміняти місцями", + "Swap Aspect (X)": "Поміняти пропорції (X)", + "Swap Before and After": "Поміняти місцями «до» й «після»", + "Swap Before and After Settings": "Поміняти місцями налаштування «до» й «після»", + "Swap Compare Photos": "Поміняти місцями порівнювані фото", + "Sync Settings": "Синхронізувати налаштування", + "System default": "Типові системні налаштування", + "Tags": "Теги", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Теги: див. «Теги» біля шаблону. Файли перейменовуються на диску (разом із супровідними XMP); до наявних назв додається -1, -2…", + "Targeted Adjustment": "Цільова корекція", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Цільова корекція: тягніть угору/вниз на фото, щоб змінити кольори в цьому місці", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Цільова корекція: тягніть угору/вниз на фото, щоб змінити криву в цьому місці", + "Teal & Amber": "Бірюза й бурштин", + "Teal & Orange": "Бірюза й оранжевий", + "Temp": "Температура", + "That key can't be used as a shortcut": "Цю клавішу не можна використовувати як скорочення", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Модель SAM 3 встановлено: маски «Об’єкт» і «Описати» готові.", + "The download didn't work:": "Завантаження не вдалося:", + "The number at the end of the original name (IMG_0042 → 0042)": "Число в кінці початкової назви (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Початкове розширення завжди додається; {ext} лише вставляє його також усередину назви.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Початкові файли без змін, кожен із супровідним XMP зі змінами.", + "The photo has these settings": "Фото має ці налаштування", + "They can be restored from Recently Deleted until it is emptied.": "Їх можна відновити з нещодавно видалених до очищення цього розділу.", + "Thirds": "Третини", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Ця тека недоступна (диск від’єднано?). Смарт-перегляди не створюються й не використовуються, доки вона не стане доступною або ви не виберете іншу теку.", + "This photo can't be opened": "Не вдалося відкрити це фото", + "Thumbnail cache": "Кеш мініатюр", + "Time zone": "Часовий пояс", + "Title": "Заголовок", + "Title metadata": "Метадані заголовка", + "Toggle Keyword from Set": "Перемкнути ключове слово з набору", + "Toggle Mask": "Перемкнути маску", + "Toggle Section": "Перемкнути розділ", + "Toggle Zoom": "Перемкнути масштаб", + "Top Level": "Верхній рівень", + "Treatment": "Обробка", + "Try Saving Again": "Спробувати зберегти знову", + "Tungsten": "Лампа розжарювання", + "Turn Off": "Вимкнути", + "Turn on Include subfolders, or pick another folder under Local": "Увімкніть включення вкладених тек або виберіть іншу теку в локальних", + "Uncheck All": "Зняти всі позначки", + "Uncheck the files of this type": "Зняти позначки з файлів цього типу", + "Unedited": "Без змін", + "Unedited raws": "Незмінені RAW", + "Unflag": "Зняти прапорець", + "Unflagged": "Без прапорця", + "Ungroup Stack": "Розгрупувати стос", + "Unknown tags stay as typed — check the preview for a {typo}.": "Невідомі теги залишаються як введено — перевірте перегляд на {typo}.", + "Unrated": "Без оцінки", + "Unreadable": "Нечитабельне", + "Update": "Оновити", + "Update Mask Shape": "Оновити форму маски", + "Update Preset with Current Settings": "Оновити набір налаштувань поточними значеннями", + "Update Rules from Current Filter": "Оновити правила з поточного фільтра", + "Update Version": "Оновити версію", + "Update Version with Current Settings": "Оновити версію поточними налаштуваннями", + "Update with Current Settings": "Оновити поточними налаштуваннями", + "Updated the edits saved in the external editor": "Зміни, збережені в зовнішньому редакторі, оновлено", + "Upright": "Випрямлення", + "Urban": "Міське", + "Use Keyword Set": "Використовувати набір ключових слів", + "Use Library Folder": "Використовувати теку бібліотеки", + "Use camera-specific defaults": "Використовувати типові налаштування для камери", + "Use the GPU for rendering": "Використовувати ГП для рендерингу", + "User Presets": "Власні набори налаштувань", + "Value": "Значення", + "Version name": "Назва версії", + "Versions": "Версії", + "Versions (Shift+V)": "Версії (Shift+V)", + "Videos": "Відео", + "Viewing and sharing": "Перегляд і поширення", + "Vignette": "Віньєтка", + "Vintage": "Вінтажне", + "Virtual Copy": "Віртуальна копія", + "Visualize Spots": "Показати плями", + "Visualize Spots (A)": "Показати плями (A)", + "Visualize range": "Показати діапазон", + "Vivid": "Яскраве", + "Vivid Pop": "Виразна яскравість", + "Warm Film": "Тепла плівка", + "Warm Glow": "Тепле сяйво", + "Warm Print": "Теплий відбиток", + "Warm Table": "Теплий стіл", + "Watched folder": "Відстежувана тека", + "Watermark": "Водяний знак", + "What's New": "Що нового", + "When rated or hovered": "Після оцінювання або наведення", + "White Balance": "Баланс білого", + "White Balance Selector": "Вибір балансу білого", + "White Balance Selector (W)": "Вибір балансу білого (W)", + "Width (% of photo)": "Ширина (% фото)", + "Width (px)": "Ширина (пікселів)", + "Width × Height": "Ширина × висота", + "Will be replaced": "Буде замінено", + "Will be restored": "Буде відновлено", + "Winter Blue": "Зимова синява", + "XMP Sidecar Preferences": "Налаштування супровідних файлів XMP", + "XMP sidecars": "Супровідні файли XMP", + "Year": "Рік", + "Yellow Hue": "Тон жовтого", + "Yellow Luminance": "Яскравість жовтого", + "Yellow Saturation": "Насиченість жовтого", + "Yellow label": "Жовта мітка", + "You can close this: the download continues, and resumes if interrupted.": "Можна закрити це вікно: завантаження триває й відновиться після переривання.", + "Your Name": "Ваше ім’я", + "Zoom 100%": "Масштаб 100%", + "Zoom to Fit": "Масштабувати до вміщення", + "a literal %": "буквальний %", + "a removed photo": "вилучене фото", + "all": "усі", + "and": "і", + "any": "будь-яке", + "are empty": "порожні", + "aren't empty": "не порожні", + "blue": "синій", + "comma, separated": "розділені комами", + "contains": "містить", + "copyrighted": "захищено авторським правом", + "day 01–31": "день 01–31", + "days": "днів", + "discarding smart previews": "видалення смарт-переглядів", + "doesn't contain": "не містить", + "e.g. sky · the red car · car, road": "наприклад, небо · червона машина · машина, дорога", + "ends with": "закінчується на", + "false": "хибне", + "green": "зелений", + "hour 00–23": "година 00–23", + "hours": "годин", + "image": "зображення", + "is": "дорівнює", + "is <": "<", + "is >": ">", + "is after": "після", + "is before": "до", + "is between": "між", + "is empty": "порожнє", + "is in the last": "за останні", + "is unknown": "невідоме", + "is ≤": "≤", + "is ≥": "≥", + "isn't": "не дорівнює", + "isn't empty": "не порожнє", + "isn't in the last": "не за останні", + "keep names — or e.g. {date}_{seq:3}": "залишити назви — або, наприклад, {date}_{seq:3}", + "latitude, longitude": "широта, довгота", + "memory only": "лише в пам’яті", + "minute": "хвилина", + "month 01–12": "місяць 01–12", + "months": "місяців", + "none": "немає", + "none (CPU rendering)": "немає (рендеринг на ЦП)", + "none of": "жодне з", + "not available here": "недоступно тут", + "of the following rules:": "таких правил:", + "Interface language": "Мова інтерфейсу", + "New blank PDF": "Новий порожній PDF", + "Create PDF from file…": "Створити PDF з файлу…", + "Create PDF from images…": "Створити PDF із зображень…", + "Create PDF from clipboard": "Створити PDF з буфера обміну", + "Combine files…": "Об’єднати файли…", + "Save as…": "Зберегти як…", + "Close file": "Закрити файл", + "Close all": "Закрити всі", + "Document properties…": "Властивості документа…", + "Advanced search…": "Розширений пошук…", + "Copy pages": "Копіювати сторінки", + "Cut pages": "Вирізати сторінки", + "Paste pages": "Вставити сторінки", + "Fit visible": "Умістити видиме", + "Marquee zoom": "Збільшення рамкою", + "Take a snapshot": "Зробити знімок", + "Full screen mode": "Повноекранний режим", + "Read mode": "Режим читання", + "Switch light / dark theme": "Перемкнути світлу / темну тему", + "Comments panel": "Панель коментарів", + "Form fields panel": "Панель полів форми", + "Clear form": "Очистити форму", + "Find tools and commands…": "Знайти інструменти й команди…", + "Actual size": "Фактичний розмір", + "Zoom to page level": "Масштаб до рівня сторінки", + "Fit to width": "Умістити за шириною", + "Display theme": "Тема відображення", + "Side panels": "Бічні панелі", + "Text direction": "Напрямок тексту", + "Horizontal text": "Горизонтальний текст", + "Vertical text": "Вертикальний текст", + "Your library couldn't be opened": "Не вдалося відкрити вашу бібліотеку", + "LightCraft couldn't find where to keep your library.": "LightCraft не вдалося знайти місце для бібліотеки.", + "LightCraft couldn't open the library at": "LightCraft не вдалося відкрити бібліотеку в", + "Try Again": "Спробувати знову", + "Choose Another Library…": "Вибрати іншу бібліотеку…", + "Continue Without Saving": "Продовжити без збереження", + "Quit": "Вийти", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Тимчасовий сеанс — бібліотеку не відкрито, і жодна дія тут не зберігається.", + "Back Up Library…": "Створити резервну копію бібліотеки…", + "Restore Library from Backup…": "Відновити бібліотеку з резервної копії…", + "In Recently Deleted": "У нещодавно видалених", + "Reload from Disk": "Перезавантажити з диска", + "Monday": "Понеділок", + "Tuesday": "Вівторок", + "Wednesday": "Середа", + "Thursday": "Четвер", + "Friday": "П’ятниця", + "Saturday": "Субота", + "Sunday": "Неділя", + "Unknown Date": "Невідома дата", + "Remove Folder from Library": "Вилучити теку з бібліотеки", + "Remove from Library…": "Вилучити з бібліотеки…", + "This Computer": "Цей комп’ютер", + "includes the folders inside it": "включає вкладені теки", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Вони переміщуються до нещодавно видалених і можуть бути відновлені; файли на диску не змінюються.", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Переміщує імпортовані з цієї теки фото до нещодавно видалених; файли не змінюються", + "Remove Disk from Library…": "Вилучити диск із бібліотеки…", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Переміщує всі імпортовані з цього диска фото до нещодавно видалених; файли не змінюються", + "Remove Disk from Library": "Вилучити диск із бібліотеки", + "All the photos imported from this disk are included.": "Включаються всі імпортовані з цього диска фото.", + "That includes the photos in the folders inside it.": "Також включаються фото у вкладених теках.", + "or": "або", + "pick": "відібране", + "previews": "перегляди", + "publicDomain": "суспільне надбання", + "purple": "фіолетовий", + "raw": "RAW", + "red": "червоний", + "reject": "відхилене", + "saved in": "збережено в", + "second": "секунда", + "smart previews": "смарт-перегляди", + "starts with": "починається з", + "the photos in view": "фото в поданні", + "the selected photos": "вибрані фото", + "this photo": "це фото", + "true": "істинне", + "unknown": "невідоме", + "video": "відео", + "weeks": "тижнів", + "year, 2 digits": "рік, 2 цифри", + "year, 4 digits": "рік, 4 цифри", + "years": "років", + "yellow": "жовтий", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} — час знімання; фото без нього використовує час імпорту.", + "© 2026 Your Name": "© 2026 Ваше ім’я", + "© Your Name": "© Ваше ім’я", + "− harder border · + softer border": "− твердіша межа · + м’якша межа", + "≤ at most": "≤ щонайбільше", + "≥ at least": "≥ щонайменше", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "How to install the model": "Як установити модель", + "Install the SAM 3 Model": "Установити модель SAM 3", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Ця збірка ще не може завантажити модель. Щоб установити її вручну, покладіть model.safetensors, vocab.json і merges.txt із facebook/sam3 від Meta до теки вище: «Об’єкт» і «Описати» працюватимуть одразу після цього.", + "Open Log Folder": "Відкрити теку журналів", + "Back to this person": "Повернутися до цієї особи", + "AI Denoise": "ШІ: усунення шуму", + "AI Denoise Model": "Модель усунення шуму ШІ", + "AI Denoise is off: press Use on a model.": "Усунення шуму ШІ вимкнено: натисніть «Використовувати» на моделі.", + "AI Denoise needs a model. Install one in Settings.": "Для усунення шуму ШІ потрібна модель. Установіть її в налаштуваннях.", + "Accept & Download": "Прийняти й завантажити", + "Already installed. Remove it before installing a replacement.": "Уже встановлено. Видаліть перед установленням заміни.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Автоматичний режим використовує відеокарту, коли вона швидша за процесор: час обох вимірюється на цьому комп’ютері під час першої обробки фото.", + "Cache": "Кеш", + "Checking and installing the model…": "Перевірка й установлення моделі…", + "Checking…": "Перевірка…", + "Clear cache": "Очистити кеш", + "Denoise Models": "Моделі усунення шуму", + "Denoise Settings": "Налаштування усунення шуму", + "Denoise all photos with an Amount": "Обробити всі фото зі значенням усунення шуму", + "Downloaded and checked. Installing…": "Завантажено й перевірено. Установлення…", + "Failed its last test": "Останню перевірку не пройдено", + "Graphics card": "Відеокарта", + "I have read these terms and accept them for my own use": "Я прочитав ці умови й приймаю їх для власного використання", + "Inspect Denoise Model": "Перевірити модель усунення шуму", + "Install Denoise Model": "Установити модель усунення шуму", + "Install from file…": "Установити з файлу…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Установіть власну модель ONNX із denoise-model.json поруч. Ця збірка не пропонує завантаження моделей.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Зображення з усуненим шумом ще не створено, а автоматичне створення вимкнено (Налаштування, ШІ: усунення шуму).", + "LightCraft does not know this model, so it assumed:": "LightCraft не знає цю модель, тому припускає:", + "Limit": "Обмеження", + "Make it now": "Створити зараз", + "Make the denoised picture of the photos I am looking at": "Створити зображення з усуненим шумом для фото, які я переглядаю", + "Next up for AI Denoise…": "Наступне на усунення шуму ШІ…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Нічого не завантажується до вашої згоди. Модель зберігається лише на цьому комп’ютері; LightCraft ніколи не вивантажує й не поширює її.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Обробляються лише фото зі значенням усунення шуму ШІ понад 0. Експорт завжди створює потрібне зображення, якщо його немає.", + "Open page": "Відкрити сторінку", + "Open the model's page": "Відкрити сторінку моделі", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Відкриває сторінку моделі в браузері, щоб прочитати про неї або отримати файл самостійно.", + "Processor": "Процесор", + "Set up AI Denoise…": "Налаштувати усунення шуму ШІ…", + "Starting…": "Запуск…", + "Tested and works on this computer": "Перевірено й працює на цьому комп’ютері", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Модель усунення шуму встановлено й використовується: повзунок інтенсивності усунення шуму ШІ в деталях тепер працює", + "The graphics card is set up when the next photo is made.": "Відеокарта налаштовується під час обробки наступного фото.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Модель зберігається лише на цьому комп’ютері. LightCraft ніколи не вивантажує й не поширює її.", + "The processor does the work.": "Роботу виконує процесор.", + "There is no model file to install": "Немає файлу моделі для встановлення", + "This build cannot run denoise models.": "Ця збірка не може запускати моделі усунення шуму.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Ця збірка не має місця для моделей усунення шуму, тому ця функція ШІ недоступна. (Настільний застосунок має.)", + "This file cannot be used yet": "Цей файл поки не можна використовувати", + "Tick the box to accept the model's terms first": "Спочатку позначте прийняття умов моделі", + "Try again": "Спробувати знову", + "Try the graphics card again": "Спробувати відеокарту знову", + "Turn off": "Вимкнути", + "Unknown licence": "Невідома ліцензія", + "Use": "Використовувати", + "Waiting to start…": "Очікування запуску…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Працює з файлами RAW від камер зі стандартним сенсором (Баєра). Інші файли зберігають звичайне зменшення шуму.", + "cached picture": "Кешовані зображення", + "cached pictures": "Кешовані зображення", + "commercial use allowed": "комерційне використання дозволено", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "використовується", + "installed": "встановлено", + "makes AI Denoise available": "надає доступ до усунення шуму ШІ", + "non-commercial use only": "лише некомерційне використання", + "photo has its denoised picture.": "Фото із зображенням з усуненим шумом", + "photo waiting": "Фото в черзі", + "photos have their denoised picture.": "Фото із зображенням з усуненим шумом", + "photos waiting": "Фото в черзі", + "starts using it for AI Denoise": "починає використовувати модель для усунення шуму ШІ", + "terms unclear": "умови незрозумілі", + "the graphics card": "відеокарта", + "the graphics card is not used": "відеокарта не використовується", + "Cancel Denoise": "Скасувати усунення шуму", + "Cancel Denoise Model Download": "Скасувати завантаження моделі усунення шуму", + "Choose Denoise Model": "Вибрати модель усунення шуму", + "Clear Denoise Cache": "Очистити кеш усунення шуму", + "Denoise Model Downloads": "Завантаження моделей усунення шуму", + "Denoise Photos": "Обробити фото з усуненням шуму", + "Denoise Pump": "Запустити чергу усунення шуму", + "Denoise Status": "Стан усунення шуму", + "Download Denoise Model": "Завантажити модель усунення шуму", + "Remove Denoise Model": "Видалити модель усунення шуму", + "Test Denoise Model": "Перевірити модель усунення шуму", + "AI Denoise is enabled on the requesting photo": "Усунення шуму ШІ ввімкнено для фото, що запросило дію", + "After installation, AI Denoise will turn on for the requesting photo.": "Після встановлення усунення шуму ШІ ввімкнеться для фото, що запросило дію.", + "Cancel pending action": "Скасувати відкладену дію", + "Install a denoise model to enable AI Denoise on this photo": "Установіть модель усунення шуму, щоб увімкнути його для цього фото", + "Pending photo action cancelled": "Відкладену дію фото скасовано", + "Pending photo action cancelled because its library or photo changed": "Відкладену дію фото скасовано, оскільки змінилася бібліотека або фото", + "ONNX model": "Модель ONNX", + "commercial use subject to GPL conditions": "комерційне використання за умов GPL", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "«збережено в пам’яті, але не записано на диск», верхня панель показує попередження, а LightCraft повторює спроби, доки", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): DNG перевіряється за даними RAW, записується до тимчасового файлу, синхронізується й перечитується, перш ніж отримати", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Довідка → Інформація про систему й Налаштування → Продуктивність пояснюють, чому ГП не", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(наприклад, D750, D780, D850, D7500, Z 50) більше не відтворюються майже чорними або зі знищеними тінями: їхній рівень чорного", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(проблема #94): HTTP-запит із вебсторінки більше не може містити команду в тілі. Рядки обмежено", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 на studio-mac)»), замість того щоб дозволити обом писати й непомітно втрачати зміни одне одного. Аварія ніколи не залишає", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "перегляд 1620 × 1080 та імпортуються з метаданими: час знімання, камера, об’єктив, експозиція, GPS і XMP. Їхні дані RAW", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 нові вбудовані набори налаштувань: портрет, пейзаж, місто, їжа, пори року, вінтаж і чорно-біле тонування.", + "4 MiB and connections at 16.": "4 МіБ, а кількість з’єднань — 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630): у Windows вікно й рендеринг на ГП використовують лише DirectX 12 і не завантажують драйвер Vulkan, доки ви", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Зміна, яку не можна зберегти на диск (заповнений або від’єднаний), більше не залишається непомітною: команда повідомляє", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Бібліотека відкрита лише в одній програмі одночасно (проблема #99): відкриття бібліотеки, яку LightCraft або `lightcraft-cli` уже", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Невдале часткове збереження (повний диск, розрив мережевого підключення) більше не виглядає як пошкоджений каталог", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Сплячий NAS, розірване мережеве підключення або USB-диск, що розкручується, більше не заморожують вікно (проблема #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Маски ШІ із SAM 3 («Об’єкт» і «Описати» на панелі маскування): клацніть об’єкт, щоб виділити його (⌥-клацання виключає", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Автосинхронізація: зміни застосовуються до кожного вибраного фото. Автоматичне змішування чорно-білого. Автоматичні версії.", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Автоматичне позначення за журналом маршруту: розташування GPS для фото з журналу GPX за часом знімання.", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Браузерна версія (експериментальна), захист бібліотеки (проблема #107): Файл → Створити резервну копію бібліотеки… завантажує каталог", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Фото Canon CR2 з EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II та 1D Mark IV (та інших", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Файли Canon CR3 показують повнорозмірний вбудований JPEG (наприклад, 6960 × 4640 на EOS R6 Mark III) замість", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R, зберігає фото й скидає його зміни.)", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Фільтр кольорових міток із кількома мітками одночасно; дерево тек у локальних із розгортанням.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Маски колірного діапазону: клацніть фото для зразка. Діапазони яскравості: смуга діапазону, плавність, карта яскравості.", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Стиснені Nikon NEF (без втрат і з втратами, 12- і 14-бітні — наприклад, D3200, D5100, D7000, D750, D850, Z 50)", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "Перетворення на DNG, копіювання як DNG, об’єднання фото й смарт-перегляди більше не пишуть прямо до кінцевого файлу (проблема", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Статус авторського права, умови використання прав і URL інформації про авторське право в інформації, наборах метаданих і експорті.", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "налаштування краю й отримують чіткіший збільшений результат у фоні. Модель працює всередині LightCraft на чистому Rust", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Редагування в зовнішньому редакторі (⇧⌘E): 16-бітна копія TIFF у стосі, оновлена після повернення.", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Назви експортованих файлів використовують теги перейменування фото ({title}, {seq:2}, {date:%Y-%m-%d}…), а також нові {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Експортовані файли пишуться до тимчасового файлу, а потім перейменовуються на місце, тому заповнений або від’єднаний диск", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Експорт і рендеринг ніколи не перезаписують оригінал фото (проблема #93): експорт до власної теки фото з", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Експорт ніколи не стає чорним через ГП (проблема #78): рендеринг на ГП, який вичерпує пам’ять пристрою,", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Швидші експорт та імпорт із карт, із тим самим захистом невідтворюваних даних (проблема #134): експорт,", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Якщо бібліотеку неможливо відкрити під час запуску (відкрита в іншій програмі, не читається, на непідключеному диску,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Імпорт наборів налаштувань з інших редакторів: XMP, класичні `.lrtemplate`, «набори DNG» із мобільних застосунків і `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Імпорт → Копіювати перевіряє кожну копію, як і переміщення (проблема #96): кожен файл створюється як новий, синхронізується на диск і", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Імпорт → Перемістити: фото переходять до призначення (із тими самими теками й перейменуванням, що й копіювання, наприклад", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Імпорт: копіювання до будь-якої теки, за днем / місяцем / до однієї теки, перейменування під час імпорту, набір метаданих, копіювання як DNG.", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG із тією самою назвою (`IMG_0001.CR3` + `IMG_0001.JPG`) більше не мають спільного супровідного файлу: RAW зберігає `IMG_0001.xmp`,", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 і старіші відмовляються його відкрити («непідтримуваний формат … v2»).", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft більше не падає під час запуску на ПК Windows із несправним драйвером Vulkan (проблема #136, наприклад, деякі Intel UHD", + "Lossy DNG files and Smart Previews open as raw photos.": "DNG із втратами й смарт-перегляди відкриваються як фото RAW.", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Стилі Luminar: файли `.lmp` і колекції `.mplumpack` імпортуються як набори налаштувань (згруповані за колекцією); повзунки", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Nikon NEF починають із кольору й тону, підігнаних до власного JPEG камери, як і Sony ARW, замість приглушеного", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon із двома половинами різного стиснення поки використовують перегляд.)", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Panasonic і Leica RAW (RW2, RWL і старі RAW, від DMC-LX1 до DC-S1R II) обробляються з даних RAW у", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "меню «Фото». Стрічка також має контекстне меню фото. Повторне додавання файлу з нещодавно видалених більше не", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Фото в нещодавно видалених можна відновити із застосунку: права кнопка → Відновити (або Видалити назавжди), також у", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Фото); імпорт (зокрема перетягування), пошук відсутніх фото, створення / видалення смарт-переглядів, перегляд перейменування,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Швидка колекція й цільовий альбом (B у сітці), набори ключових слів (⌥1–⌥9), набори кольорових міток.", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Перейменування теки… й переміщення теки до… (Локальні) можна скасувати й повторити (проблема #97): тека повертається на диску разом із", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Перейменування фото ніколи не перезаписує інше фото, коли змінюється лише регістр літер (проблема #95): на томах із чутливістю", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Перейменування фото повідомляє про файли, які не вдалося повернути після помилки (проблема #105), наприклад, розриву мережевого підключення", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Збереження метаданих до XMP, написаного іншим застосунком, більше не замінює його (проблема #92): LightCraft додає свої", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Файли налаштувань ніколи не скидаються непомітно (проблема #103): пошкоджені `prefs.json`, `presets.json` або `view.json` зберігаються", + "Slideshow, second window, All Metadata, System Info.": "Показ слайдів, друге вікно, усі метадані, інформація про систему.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Менші й швидші каталоги: фото, які ви лише переглядали в локальних (ніколи не додавали, не оцінювали й не редагували), забуваються, коли", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Смарт-альбоми з редактором правил: усі / будь-які / жодні, вкладені групи, 26 полів.", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony ARW приблизно до 2017 року (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II та споріднені", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Sony ILCE-7M4 ARW зменшеного розміру без втрат тепер декодують ділянки YCbCr зі зменшеною дискретизацією в лінійний RGB,", + "TZ/ZS series, Leica D-Lux, V-Lux and C-Lux, and the GH6, GH7, G9 II, S5 II and S9 generation. They start from a": "серії TZ/ZS, Leica D-Lux, V-Lux і C-Lux та покоління GH6, GH7, G9 II, S5 II і S9. Вони починають із", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Каталог має версію формату (проблема #102). Відкриття бібліотеки старішого LightCraft оновлює її; бібліотека,", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Порт керування (`--control`) закриває з’єднання, щойно отримує щось, що не є запитом JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Кеш мініатюр завжди рахує й видаляє лише власні файли (проблема #98): бібліотека, відкрита в теці, яка", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Автоімпорт із відстежуваної теки; перетворення на DNG; дублювання; створення стандартних переглядів / 1:1 / смарт-переглядів.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "без збереження й вихід (проблема #100). Більше не запускається непомітний демонстраційний сеанс, схожий на скинуту бібліотеку, який", + "`Photos/2026/20260114/20260114_001.jpg`) together with their XMP sidecars; each original leaves the card only after its": "`Photos/2026/20260114/20260114_001.jpg`) разом із супровідними XMP; кожен оригінал залишає карту лише після", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; на томах без чутливості до регістру зміна регістру виконується.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "повний диск) більше не вважається створеним, а створення смарт-переглядів замінює його.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "після цього (проблема #101): частковий запис обрізається перед повторною спробою LightCraft, тому наступний запуск відтворює кожну", + "again. Change the period (or turn it off) in Settings → Performance.": "знову. Змініть період (або вимкніть) у Налаштування → Продуктивність.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "за оригіналами бібліотеки більше не сканує всю бібліотеку, коли за цим шляхом нічого немає.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "вже має теку `thumbs/` з іншими зображеннями, більше не втрачає їх під час скорочення або очищення кешу.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "і перевіряє файл перед використанням. Маски зберігають виділення, тому рендеряться й експортуються без моделі.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "й кожне імпортоване фото як один zip, а Файл → Відновити бібліотеку з резервної копії… повертає його (поточна бібліотека", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "і ніколи не заморожує вікно. Вона необов’язкова й не є частиною LightCraft (ліцензія SAM від Meta, приблизно 3,4 ГБ):", + "as `.corrupt-